@agent-native/dispatch 0.22.1 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/actions/provider-api-register.d.ts +1 -1
  2. package/dist/components/app-icon.d.ts +2 -1
  3. package/dist/components/app-icon.d.ts.map +1 -1
  4. package/dist/components/app-icon.js +51 -29
  5. package/dist/components/app-icon.js.map +1 -1
  6. package/dist/components/app-list-row.d.ts +1 -1
  7. package/dist/components/app-list-row.d.ts.map +1 -1
  8. package/dist/components/app-list-row.js +1 -1
  9. package/dist/components/app-list-row.js.map +1 -1
  10. package/dist/components/app-open-actions.d.ts +2 -1
  11. package/dist/components/app-open-actions.d.ts.map +1 -1
  12. package/dist/components/app-open-actions.js +2 -2
  13. package/dist/components/app-open-actions.js.map +1 -1
  14. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  15. package/dist/components/dispatch-control-plane.js +16 -17
  16. package/dist/components/dispatch-control-plane.js.map +1 -1
  17. package/dist/components/layout/Layout.d.ts.map +1 -1
  18. package/dist/components/layout/Layout.js +137 -66
  19. package/dist/components/layout/Layout.js.map +1 -1
  20. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  21. package/dist/components/layout/workspace-apps-rail.js +1 -1
  22. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  23. package/dist/components/other-apps-section.d.ts +3 -1
  24. package/dist/components/other-apps-section.d.ts.map +1 -1
  25. package/dist/components/other-apps-section.js +9 -6
  26. package/dist/components/other-apps-section.js.map +1 -1
  27. package/dist/components/workspace-app-card.d.ts.map +1 -1
  28. package/dist/components/workspace-app-card.js +7 -2
  29. package/dist/components/workspace-app-card.js.map +1 -1
  30. package/dist/components/workspace-template-card.d.ts.map +1 -1
  31. package/dist/components/workspace-template-card.js +2 -5
  32. package/dist/components/workspace-template-card.js.map +1 -1
  33. package/dist/lib/overview-chat.d.ts.map +1 -1
  34. package/dist/lib/overview-chat.js +0 -8
  35. package/dist/lib/overview-chat.js.map +1 -1
  36. package/dist/routes/pages/chat.js +1 -1
  37. package/dist/routes/pages/chat.js.map +1 -1
  38. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  39. package/dist/server/lib/mcp-gateway.js +14 -1
  40. package/dist/server/lib/mcp-gateway.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/components/app-icon.tsx +71 -43
  43. package/src/components/app-list-row.tsx +1 -1
  44. package/src/components/app-open-actions.tsx +4 -2
  45. package/src/components/dispatch-control-plane.spec.tsx +45 -7
  46. package/src/components/dispatch-control-plane.tsx +56 -75
  47. package/src/components/layout/Layout.spec.tsx +53 -0
  48. package/src/components/layout/Layout.tsx +227 -99
  49. package/src/components/layout/workspace-apps-rail.tsx +1 -0
  50. package/src/components/other-apps-section.tsx +61 -40
  51. package/src/components/workspace-app-card.spec.tsx +63 -66
  52. package/src/components/workspace-app-card.tsx +10 -3
  53. package/src/components/workspace-template-card.spec.tsx +7 -8
  54. package/src/components/workspace-template-card.tsx +21 -11
  55. package/src/lib/overview-chat.spec.ts +0 -19
  56. package/src/lib/overview-chat.ts +0 -9
  57. package/src/routes/pages/chat.tsx +1 -1
  58. package/src/server/lib/mcp-gateway.spec.ts +64 -2
  59. package/src/server/lib/mcp-gateway.ts +19 -1
  60. package/src/styles/dispatch-css.spec.ts +11 -0
  61. package/src/styles/dispatch.css +2 -2
@@ -1,5 +1,4 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
- import { IconStack2 } from "@tabler/icons-react";
3
2
 
4
3
  import { filterOtherApps, type ConnectedAppSummary } from "../lib/other-apps";
5
4
  import type { WorkspaceAppId } from "../lib/other-apps";
@@ -80,6 +79,8 @@ export function OtherAppsSection({
80
79
  onRetryConnectedApps,
81
80
  templateLabels,
82
81
  onRemixSuccess,
82
+ heading = "Other apps",
83
+ embeddedInList = false,
83
84
  className,
84
85
  }: {
85
86
  templates?: CuratedWorkspaceTemplatesResult;
@@ -96,6 +97,8 @@ export function OtherAppsSection({
96
97
  result: unknown,
97
98
  template: CuratedWorkspaceTemplate,
98
99
  ) => void;
100
+ heading?: string | null;
101
+ embeddedInList?: boolean;
99
102
  className?: string;
100
103
  }) {
101
104
  const t = useT();
@@ -109,26 +112,15 @@ export function OtherAppsSection({
109
112
 
110
113
  if (!isLoading && !hasError && entries.length === 0) return null;
111
114
 
112
- return (
113
- <section className={cn("space-y-3 border-t pt-6", className)}>
114
- <div className="flex min-w-0 items-start gap-2">
115
- <IconStack2
116
- size={16}
117
- className="mt-0.5 shrink-0 text-muted-foreground"
118
- />
119
- <div className="min-w-0">
115
+ const content = (
116
+ <>
117
+ {heading ? (
118
+ <div className="flex min-w-0 items-center">
120
119
  <h2 className="truncate text-sm font-semibold text-foreground">
121
- {t("dispatch.pages.otherApps", { defaultValue: "Other apps" })}
120
+ {t("dispatch.pages.otherApps", { defaultValue: heading })}
122
121
  </h2>
123
- {entries.length > 0 ? (
124
- <p className="mt-0.5 text-xs text-muted-foreground">
125
- {t("dispatch.pages.availableCount", {
126
- count: entries.length,
127
- })}
128
- </p>
129
- ) : null}
130
122
  </div>
131
- </div>
123
+ ) : null}
132
124
 
133
125
  {templatesError ? (
134
126
  <ActionQueryError
@@ -144,31 +136,37 @@ export function OtherAppsSection({
144
136
  ) : null}
145
137
 
146
138
  {isLoading && entries.length === 0 ? (
147
- <OtherAppsSkeletonList />
139
+ embeddedInList ? (
140
+ <OtherAppsSkeletonRows />
141
+ ) : (
142
+ <OtherAppsSkeletonList />
143
+ )
148
144
  ) : entries.length > 0 ? (
149
- <AppList className={APP_LIST_GRID_CLASS}>
150
- {entries.map((entry) =>
151
- entry.kind === "template" ? (
152
- <WorkspaceTemplateCard
153
- key={`template:${templateKey(entry.template)}`}
154
- template={entry.template}
155
- labels={templateLabels}
156
- catalog
157
- className={APP_LIST_GRID_ROW_CLASS}
158
- onRemixSuccess={onRemixSuccess}
159
- />
160
- ) : (
161
- <ConnectedAppCard
162
- key={`connected:${entry.app.id}`}
163
- app={entry.app}
164
- className={APP_LIST_GRID_ROW_CLASS}
165
- />
166
- ),
167
- )}
168
- </AppList>
145
+ entries.map((entry) =>
146
+ entry.kind === "template" ? (
147
+ <WorkspaceTemplateCard
148
+ key={`template:${templateKey(entry.template)}`}
149
+ template={entry.template}
150
+ labels={templateLabels}
151
+ catalog
152
+ className={APP_LIST_GRID_ROW_CLASS}
153
+ onRemixSuccess={onRemixSuccess}
154
+ />
155
+ ) : (
156
+ <ConnectedAppCard
157
+ key={`connected:${entry.app.id}`}
158
+ app={entry.app}
159
+ className={APP_LIST_GRID_ROW_CLASS}
160
+ />
161
+ ),
162
+ )
169
163
  ) : null}
170
- </section>
164
+ </>
171
165
  );
166
+
167
+ if (embeddedInList) return content;
168
+
169
+ return <section className={cn("space-y-3", className)}>{content}</section>;
172
170
  }
173
171
 
174
172
  function OtherAppsSkeletonList() {
@@ -193,3 +191,26 @@ function OtherAppsSkeletonList() {
193
191
  </AppList>
194
192
  );
195
193
  }
194
+
195
+ function OtherAppsSkeletonRows() {
196
+ return (
197
+ <>
198
+ {Array.from({ length: 4 }).map((_, index) => (
199
+ <div
200
+ key={index}
201
+ className={cn(
202
+ "flex min-w-0 items-center gap-3 border-b px-4 py-3.5 last:border-b-0",
203
+ APP_LIST_GRID_ROW_CLASS,
204
+ )}
205
+ >
206
+ <Skeleton className="size-8 shrink-0 rounded-xl" />
207
+ <div className="min-w-0 flex-1 space-y-2">
208
+ <Skeleton className="h-4 w-32" />
209
+ <Skeleton className="h-3 w-2/3" />
210
+ </div>
211
+ <Skeleton className="h-9 w-24 shrink-0 rounded-md" />
212
+ </div>
213
+ ))}
214
+ </>
215
+ );
216
+ }
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
+ import { MemoryRouter } from "react-router";
4
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
6
 
6
7
  import { TooltipProvider } from "./ui/tooltip";
@@ -44,22 +45,24 @@ describe("WorkspaceAppCard", () => {
44
45
  it("uses one explicit open action and one settings control", async () => {
45
46
  await act(async () => {
46
47
  root.render(
47
- <TooltipProvider>
48
- <WorkspaceAppCard
49
- app={{
50
- id: "analytics",
51
- name: "Analytics",
52
- path: "/analytics",
53
- description: "Explore product and growth performance.",
54
- status: "ready",
55
- }}
56
- />
57
- </TooltipProvider>,
48
+ <MemoryRouter>
49
+ <TooltipProvider>
50
+ <WorkspaceAppCard
51
+ app={{
52
+ id: "analytics",
53
+ name: "Analytics",
54
+ path: "/analytics",
55
+ description: "Explore product and growth performance.",
56
+ status: "ready",
57
+ }}
58
+ />
59
+ </TooltipProvider>
60
+ </MemoryRouter>,
58
61
  );
59
62
  });
60
63
 
61
64
  const appLink = container.querySelector<HTMLAnchorElement>(
62
- 'a[href="/analytics"]',
65
+ 'a[href="/apps/analytics"]',
63
66
  );
64
67
  expect(appLink).not.toBeNull();
65
68
  expect(appLink?.textContent).toContain("Open app");
@@ -67,25 +70,13 @@ describe("WorkspaceAppCard", () => {
67
70
  expect(appLink?.getAttribute("target")).toBeNull();
68
71
  expect(appLink?.getAttribute("rel")).toBeNull();
69
72
  expect(appLink?.querySelector("svg")).toBeNull();
70
- expect(container.textContent).not.toContain("/analytics");
73
+ expect(container.textContent).not.toContain("/apps/analytics");
71
74
 
72
- const openOptionsButton = container.querySelector<HTMLButtonElement>(
73
- 'button[aria-label="Open options for Analytics"]',
74
- );
75
- expect(openOptionsButton).not.toBeNull();
76
- await act(async () => {
77
- openOptionsButton?.dispatchEvent(
78
- new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
79
- );
80
- });
81
- const openInlineLink = Array.from(
82
- document.querySelectorAll<HTMLAnchorElement>('[role="menu"] a'),
83
- ).find((anchor) => anchor.textContent?.includes("Open inline"));
84
- const openNewTabLink = Array.from(
85
- document.querySelectorAll<HTMLAnchorElement>('[role="menu"] a'),
86
- ).find((anchor) => anchor.textContent?.includes("Open in new tab"));
87
- expect(openInlineLink?.getAttribute("target")).toBeNull();
88
- expect(openNewTabLink?.getAttribute("target")).toBe("_blank");
75
+ expect(
76
+ container.querySelector(
77
+ 'button[aria-label="Open options for Analytics"]',
78
+ ),
79
+ ).toBeNull();
89
80
 
90
81
  const settingsButton = container.querySelector<HTMLButtonElement>(
91
82
  'button[aria-label="Settings for Analytics"]',
@@ -107,17 +98,19 @@ describe("WorkspaceAppCard", () => {
107
98
  it("labels per-app context as agent resources while preserving workspace scope", async () => {
108
99
  await act(async () => {
109
100
  root.render(
110
- <TooltipProvider>
111
- <WorkspaceAppCard
112
- app={{
113
- id: "analytics",
114
- name: "Analytics",
115
- path: "/analytics",
116
- description: "Explore product and growth performance.",
117
- status: "ready",
118
- }}
119
- />
120
- </TooltipProvider>,
101
+ <MemoryRouter>
102
+ <TooltipProvider>
103
+ <WorkspaceAppCard
104
+ app={{
105
+ id: "analytics",
106
+ name: "Analytics",
107
+ path: "/analytics",
108
+ description: "Explore product and growth performance.",
109
+ status: "ready",
110
+ }}
111
+ />
112
+ </TooltipProvider>
113
+ </MemoryRouter>,
121
114
  );
122
115
  });
123
116
 
@@ -149,17 +142,19 @@ describe("WorkspaceAppCard", () => {
149
142
  it("opens pending Builder apps in a new tab", async () => {
150
143
  await act(async () => {
151
144
  root.render(
152
- <TooltipProvider>
153
- <WorkspaceAppCard
154
- app={{
155
- id: "new-app",
156
- name: "New app",
157
- path: "/new-app",
158
- builderUrl: "https://builder.example.com/projects/new-app",
159
- status: "pending",
160
- }}
161
- />
162
- </TooltipProvider>,
145
+ <MemoryRouter>
146
+ <TooltipProvider>
147
+ <WorkspaceAppCard
148
+ app={{
149
+ id: "new-app",
150
+ name: "New app",
151
+ path: "/new-app",
152
+ builderUrl: "https://builder.example.com/projects/new-app",
153
+ status: "pending",
154
+ }}
155
+ />
156
+ </TooltipProvider>
157
+ </MemoryRouter>,
163
158
  );
164
159
  });
165
160
 
@@ -177,20 +172,22 @@ describe("WorkspaceAppCard", () => {
177
172
  it("shows ownership metadata in the settings menu", async () => {
178
173
  await act(async () => {
179
174
  root.render(
180
- <TooltipProvider>
181
- <WorkspaceAppCard
182
- app={{
183
- id: "analytics",
184
- name: "Analytics",
185
- path: "/analytics",
186
- createdAt: "2026-07-28T12:00:00.000Z",
187
- createdBy: "creator@example.com",
188
- owner: "owner@example.com",
189
- teams: ["Growth", "Operations"],
190
- status: "ready",
191
- }}
192
- />
193
- </TooltipProvider>,
175
+ <MemoryRouter>
176
+ <TooltipProvider>
177
+ <WorkspaceAppCard
178
+ app={{
179
+ id: "analytics",
180
+ name: "Analytics",
181
+ path: "/analytics",
182
+ createdAt: "2026-07-28T12:00:00.000Z",
183
+ createdBy: "creator@example.com",
184
+ owner: "owner@example.com",
185
+ teams: ["Growth", "Operations"],
186
+ status: "ready",
187
+ }}
188
+ />
189
+ </TooltipProvider>
190
+ </MemoryRouter>,
194
191
  );
195
192
  });
196
193
 
@@ -20,12 +20,14 @@ import {
20
20
  IconWorld,
21
21
  } from "@tabler/icons-react";
22
22
  import { useEffect, useState, type FormEvent } from "react";
23
+ import { useNavigate } from "react-router";
23
24
  import { toast } from "sonner";
24
25
 
25
26
  import { cn } from "../lib/utils";
26
27
  import {
27
28
  isPendingBuilderHref,
28
29
  workspaceAppHref,
30
+ workspaceAppRoute,
29
31
  type WorkspaceAppSummary,
30
32
  } from "../lib/workspace-apps";
31
33
  import { ActionQueryError } from "./action-query-error";
@@ -273,7 +275,9 @@ function WorkspaceAppOpenActions({
273
275
  app: WorkspaceAppSummary;
274
276
  href: string | null;
275
277
  }) {
278
+ const navigate = useNavigate();
276
279
  const openInNewTab = isPendingBuilderHref(app);
280
+ const appRoute = workspaceAppRoute(app.id);
277
281
 
278
282
  if (!href) {
279
283
  return (
@@ -286,11 +290,14 @@ function WorkspaceAppOpenActions({
286
290
  return (
287
291
  <AppOpenActions
288
292
  name={app.name}
289
- href={href}
293
+ href={openInNewTab ? href : appRoute}
290
294
  target={openInNewTab ? "_blank" : undefined}
291
295
  rel={openInNewTab ? "noreferrer" : undefined}
292
- showInlineOption
293
- showNewTabOption
296
+ showInlineOption={openInNewTab}
297
+ onOpenInline={() => {
298
+ navigate(appRoute);
299
+ }}
300
+ showNewTabOption={openInNewTab}
294
301
  />
295
302
  );
296
303
  }
@@ -153,16 +153,15 @@ describe("WorkspaceTemplateCard", () => {
153
153
  );
154
154
  });
155
155
 
156
- it("keeps add app inside the catalog split-button menu", async () => {
156
+ it("keeps add app out of the catalog split-button menu", async () => {
157
157
  await act(async () => {
158
158
  root.render(<WorkspaceTemplateCard template={template} catalog />);
159
159
  });
160
160
 
161
- expect(
162
- Array.from(container.querySelectorAll("button")).some((button) =>
163
- button.textContent?.includes("Add app"),
164
- ),
165
- ).toBe(false);
161
+ const addButton = Array.from(container.querySelectorAll("button")).find(
162
+ (button) => button.textContent?.includes("Add app"),
163
+ );
164
+ expect(addButton).toBeDefined();
166
165
 
167
166
  const optionsButton = container.querySelector<HTMLButtonElement>(
168
167
  'button[aria-label="Open options for Weekly report"]',
@@ -177,8 +176,8 @@ describe("WorkspaceTemplateCard", () => {
177
176
  const addItem = Array.from(
178
177
  document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
179
178
  ).find((item) => item.textContent?.includes("Add app"));
180
- expect(addItem).not.toBeUndefined();
181
- await act(async () => addItem?.click());
179
+ expect(addItem).toBeUndefined();
180
+ await act(async () => addButton?.click());
182
181
  expect(document.body.textContent).toContain("Choose the URL-safe id");
183
182
  });
184
183
 
@@ -214,17 +214,27 @@ export function WorkspaceTemplateCard({
214
214
  </div>
215
215
  <div className="flex shrink-0 items-center gap-2">
216
216
  {catalog ? (
217
- <AppOpenActions
218
- name={template.name}
219
- href={liveUrl ?? null}
220
- target="_blank"
221
- rel="noreferrer"
222
- labels={{
223
- addApp: labels.remix,
224
- openApp: labels.openApp,
225
- }}
226
- onAddApp={() => setOpen(true)}
227
- />
217
+ <>
218
+ {liveUrl ? (
219
+ <AppOpenActions
220
+ name={template.name}
221
+ href={liveUrl}
222
+ target="_blank"
223
+ rel="noreferrer"
224
+ labels={{ openApp: labels.openApp }}
225
+ showNewTabOption
226
+ />
227
+ ) : null}
228
+ <Button
229
+ type="button"
230
+ variant="outline"
231
+ size="sm"
232
+ onClick={() => setOpen(true)}
233
+ >
234
+ <IconPlus />
235
+ {labels.remix}
236
+ </Button>
237
+ </>
228
238
  ) : liveUrl ? (
229
239
  <Button variant="ghost" size="sm" className="shrink-0" asChild>
230
240
  <a href={liveUrl} target="_blank" rel="noreferrer">
@@ -1,21 +1,15 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- const frameState = vi.hoisted(() => ({ inBuilderFrame: false }));
4
3
  const sendToAgentChatMock = vi.hoisted(() => vi.fn(() => "chat-tab"));
5
4
 
6
5
  vi.mock("@agent-native/core/client/agent-chat", () => ({
7
6
  sendToAgentChat: sendToAgentChatMock,
8
7
  }));
9
8
 
10
- vi.mock("@agent-native/core/client/host", () => ({
11
- isInBuilderFrame: () => frameState.inBuilderFrame,
12
- }));
13
-
14
9
  const { submitOverviewPrompt } = await import("./overview-chat.js");
15
10
 
16
11
  describe("submitOverviewPrompt", () => {
17
12
  beforeEach(() => {
18
- frameState.inBuilderFrame = false;
19
13
  sendToAgentChatMock.mockClear();
20
14
  });
21
15
 
@@ -50,19 +44,6 @@ describe("submitOverviewPrompt", () => {
50
44
  });
51
45
  });
52
46
 
53
- it("routes overview prompts to Builder chat inside Builder", () => {
54
- frameState.inBuilderFrame = true;
55
-
56
- const tabId = submitOverviewPrompt("ship the onboarding flow", "auto");
57
-
58
- expect(tabId).toBe("chat-tab");
59
- expect(sendToAgentChatMock).toHaveBeenCalledWith({
60
- message: "ship the onboarding flow",
61
- submit: true,
62
- type: "code",
63
- });
64
- });
65
-
66
47
  it("ignores empty prompts", () => {
67
48
  expect(submitOverviewPrompt(" ", "auto")).toBeNull();
68
49
  expect(sendToAgentChatMock).not.toHaveBeenCalled();
@@ -2,7 +2,6 @@ import {
2
2
  sendToAgentChat,
3
3
  type AgentChatMessage,
4
4
  } from "@agent-native/core/client/agent-chat";
5
- import { isInBuilderFrame } from "@agent-native/core/client/host";
6
5
 
7
6
  interface OverviewChatOptions {
8
7
  openSidebar?: boolean;
@@ -18,14 +17,6 @@ export function submitOverviewPrompt(
18
17
  const trimmed = message.trim();
19
18
  if (!trimmed) return null;
20
19
 
21
- if (isInBuilderFrame()) {
22
- return sendToAgentChat({
23
- message: trimmed,
24
- submit: true,
25
- type: "code",
26
- });
27
- }
28
-
29
20
  return sendToAgentChat({
30
21
  message: trimmed,
31
22
  submit: true,
@@ -174,7 +174,7 @@ export default function ChatRoute() {
174
174
  <DispatchAgentChatSurface
175
175
  mode="page"
176
176
  chatViewTransition
177
- className="dispatch-chat-panel"
177
+ className="dispatch-chat-panel px-4 sm:px-6"
178
178
  defaultMode="chat"
179
179
  storageKey="dispatch"
180
180
  threadUrlSync={threadUrlSync}
@@ -880,6 +880,54 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
880
880
  ).rejects.toThrow(/safe app-relative route/);
881
881
  });
882
882
 
883
+ it("resolves target-relative embed start URLs against the granted app", async () => {
884
+ mocks.managerCallTool.mockResolvedValueOnce({
885
+ structuredContent: {
886
+ startUrl: "/_agent-native/embed/start?ticket=relative",
887
+ },
888
+ });
889
+
890
+ const result = await runWithRequestContext(
891
+ {
892
+ userEmail: "owner@example.test",
893
+ requestOrigin: "http://localhost:8092",
894
+ },
895
+ () =>
896
+ createGrantedDispatchMcpEmbedSession({
897
+ app: "analytics",
898
+ path: "/overview",
899
+ chrome: "minimal",
900
+ }),
901
+ );
902
+
903
+ expect(result.startUrl).toBe(
904
+ "http://localhost:8086/_agent-native/embed/start?ticket=relative",
905
+ );
906
+ });
907
+
908
+ it("rejects cross-origin embed start URLs from a granted app", async () => {
909
+ mocks.managerCallTool.mockResolvedValueOnce({
910
+ structuredContent: {
911
+ startUrl: "https://attacker.example/steal?ticket=remote",
912
+ },
913
+ });
914
+
915
+ await expect(
916
+ runWithRequestContext(
917
+ {
918
+ userEmail: "owner@example.test",
919
+ requestOrigin: "http://localhost:8092",
920
+ },
921
+ () =>
922
+ createGrantedDispatchMcpEmbedSession({
923
+ app: "analytics",
924
+ path: "/overview",
925
+ chrome: "minimal",
926
+ }),
927
+ ),
928
+ ).rejects.toThrow(/invalid embed start URL/);
929
+ });
930
+
883
931
  it("rejects a URL that does not belong to the explicitly named app", async () => {
884
932
  await expect(
885
933
  runWithRequestContext(
@@ -905,6 +953,12 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
905
953
  url: "http://localhost:8092/analytics",
906
954
  },
907
955
  ]);
956
+ mocks.managerCallTool.mockResolvedValueOnce({
957
+ structuredContent: {
958
+ startUrl:
959
+ "http://localhost:8092/analytics/_agent-native/embed/start?ticket=remote",
960
+ },
961
+ });
908
962
 
909
963
  const result = await runWithRequestContext(
910
964
  {
@@ -934,7 +988,8 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
934
988
  );
935
989
  expect(result).toEqual({
936
990
  app: "analytics",
937
- startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
991
+ startUrl:
992
+ "http://localhost:8092/analytics/_agent-native/embed/start?ticket=remote",
938
993
  });
939
994
  });
940
995
 
@@ -955,6 +1010,12 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
955
1010
  color: "#2563EB",
956
1011
  },
957
1012
  ]);
1013
+ mocks.managerCallTool.mockResolvedValueOnce({
1014
+ structuredContent: {
1015
+ startUrl:
1016
+ "https://mail.agent-native.com/_agent-native/embed/start?ticket=remote",
1017
+ },
1018
+ });
958
1019
 
959
1020
  const result = await runWithRequestContext(
960
1021
  {
@@ -976,7 +1037,8 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
976
1037
  });
977
1038
  expect(result).toEqual({
978
1039
  app: "mail",
979
- startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
1040
+ startUrl:
1041
+ "https://mail.agent-native.com/_agent-native/embed/start?ticket=remote",
980
1042
  });
981
1043
  });
982
1044
 
@@ -451,6 +451,24 @@ function appBaseUrl(app: DispatchMcpAccessibleApp): string {
451
451
  return app.url.replace(/\/+$/, "");
452
452
  }
453
453
 
454
+ function resolveGrantedAppEmbedStartUrl(
455
+ app: DispatchMcpAccessibleApp,
456
+ startUrl: string,
457
+ ): string {
458
+ try {
459
+ const baseUrl = appBaseUrl(app);
460
+ const resolved = new URL(startUrl, `${baseUrl}/`);
461
+ if (!appMatchesUrlPath(app, resolved)) {
462
+ throw new Error(
463
+ "Target app returned an embed start URL outside the granted app.",
464
+ );
465
+ }
466
+ return resolved.toString();
467
+ } catch {
468
+ throw new Error("Target app returned an invalid embed start URL.");
469
+ }
470
+ }
471
+
454
472
  function appBasePath(app: DispatchMcpAccessibleApp): string {
455
473
  const pathname = new URL(appBaseUrl(app)).pathname.replace(/\/+$/, "");
456
474
  return pathname === "/" ? "" : pathname;
@@ -994,7 +1012,7 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
994
1012
  expiresAt?: number;
995
1013
  app: string;
996
1014
  } = {
997
- startUrl: parsed.startUrl,
1015
+ startUrl: resolveGrantedAppEmbedStartUrl(target.app, parsed.startUrl),
998
1016
  app: target.app.id,
999
1017
  };
1000
1018
  if (parsed.targetPath) output.targetPath = parsed.targetPath;
@@ -39,6 +39,17 @@ describe("dispatch Tailwind styles", () => {
39
39
  '@import "@agent-native/dispatch/styles/dispatch.css";',
40
40
  );
41
41
  });
42
+
43
+ it("keeps the full-page composer on the shared wide width contract", () => {
44
+ const stylesheet = fs.readFileSync(
45
+ path.join(packageRoot, "src/styles/dispatch.css"),
46
+ "utf-8",
47
+ );
48
+
49
+ expect(stylesheet).toMatch(
50
+ /\.dispatch-chat-panel \[data-agent-empty-state="centered"\] \.agent-composer-area \{[\s\S]*?max-width: min\(750px, 100%\);/,
51
+ );
52
+ });
42
53
  });
43
54
 
44
55
  describe("dispatch route shells", () => {