@agent-native/dispatch 0.41.1-nightly-20261104110058 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +26 -55
  2. package/dist/actions/get-hosted-apps-settings.d.ts +5 -0
  3. package/dist/actions/get-hosted-apps-settings.d.ts.map +1 -0
  4. package/dist/actions/get-hosted-apps-settings.js +25 -0
  5. package/dist/actions/get-hosted-apps-settings.js.map +1 -0
  6. package/dist/actions/index.d.ts.map +1 -1
  7. package/dist/actions/index.js +4 -0
  8. package/dist/actions/index.js.map +1 -1
  9. package/dist/actions/list-connected-agents.d.ts +1 -0
  10. package/dist/actions/list-connected-agents.js +11 -10
  11. package/dist/actions/list-connected-agents.js.map +1 -1
  12. package/dist/actions/list_apps.js +3 -2
  13. package/dist/actions/list_apps.js.map +1 -1
  14. package/dist/actions/provider-api-register.d.ts +10 -10
  15. package/dist/actions/set-hosted-apps-settings.d.ts +7 -0
  16. package/dist/actions/set-hosted-apps-settings.d.ts.map +1 -0
  17. package/dist/actions/set-hosted-apps-settings.js +35 -0
  18. package/dist/actions/set-hosted-apps-settings.js.map +1 -0
  19. package/dist/components/app-card-tiers.d.ts +13 -0
  20. package/dist/components/app-card-tiers.d.ts.map +1 -0
  21. package/dist/components/app-card-tiers.js +22 -0
  22. package/dist/components/app-card-tiers.js.map +1 -0
  23. package/dist/components/app-list-row.d.ts +12 -4
  24. package/dist/components/app-list-row.d.ts.map +1 -1
  25. package/dist/components/app-list-row.js +19 -4
  26. package/dist/components/app-list-row.js.map +1 -1
  27. package/dist/components/app-open-actions.d.ts +4 -0
  28. package/dist/components/app-open-actions.d.ts.map +1 -1
  29. package/dist/components/app-open-actions.js +9 -0
  30. package/dist/components/app-open-actions.js.map +1 -1
  31. package/dist/components/app-pin-button.d.ts +9 -0
  32. package/dist/components/app-pin-button.d.ts.map +1 -0
  33. package/dist/components/app-pin-button.js +29 -0
  34. package/dist/components/app-pin-button.js.map +1 -0
  35. package/dist/components/app-tier-sections.d.ts +27 -0
  36. package/dist/components/app-tier-sections.d.ts.map +1 -0
  37. package/dist/components/app-tier-sections.js +44 -0
  38. package/dist/components/app-tier-sections.js.map +1 -0
  39. package/dist/components/chat-home-apps.d.ts.map +1 -1
  40. package/dist/components/chat-home-apps.js +19 -26
  41. package/dist/components/chat-home-apps.js.map +1 -1
  42. package/dist/components/connected-app-card.d.ts +3 -1
  43. package/dist/components/connected-app-card.d.ts.map +1 -1
  44. package/dist/components/connected-app-card.js +6 -3
  45. package/dist/components/connected-app-card.js.map +1 -1
  46. package/dist/components/create-app-popover.d.ts +4 -0
  47. package/dist/components/create-app-popover.d.ts.map +1 -1
  48. package/dist/components/create-app-popover.js +6 -0
  49. package/dist/components/create-app-popover.js.map +1 -1
  50. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  51. package/dist/components/dispatch-control-plane.js +25 -35
  52. package/dist/components/dispatch-control-plane.js.map +1 -1
  53. package/dist/components/hosted-apps-settings-group.d.ts +6 -0
  54. package/dist/components/hosted-apps-settings-group.d.ts.map +1 -0
  55. package/dist/components/hosted-apps-settings-group.js +76 -0
  56. package/dist/components/hosted-apps-settings-group.js.map +1 -0
  57. package/dist/components/layout/Layout.d.ts +6 -4
  58. package/dist/components/layout/Layout.d.ts.map +1 -1
  59. package/dist/components/layout/Layout.js +97 -80
  60. package/dist/components/layout/Layout.js.map +1 -1
  61. package/dist/components/workspace-app-card.d.ts.map +1 -1
  62. package/dist/components/workspace-app-card.js +9 -20
  63. package/dist/components/workspace-app-card.js.map +1 -1
  64. package/dist/components/workspace-app-layout-notice.d.ts +9 -0
  65. package/dist/components/workspace-app-layout-notice.d.ts.map +1 -0
  66. package/dist/components/workspace-app-layout-notice.js +27 -0
  67. package/dist/components/workspace-app-layout-notice.js.map +1 -0
  68. package/dist/components/workspace-app-search.d.ts.map +1 -1
  69. package/dist/components/workspace-app-search.js +11 -5
  70. package/dist/components/workspace-app-search.js.map +1 -1
  71. package/dist/components/workspace-template-card.d.ts.map +1 -1
  72. package/dist/components/workspace-template-card.js +7 -2
  73. package/dist/components/workspace-template-card.js.map +1 -1
  74. package/dist/db/index.d.ts.map +1 -1
  75. package/dist/db/index.js +1 -0
  76. package/dist/db/index.js.map +1 -1
  77. package/dist/lib/other-apps.d.ts +10 -1
  78. package/dist/lib/other-apps.d.ts.map +1 -1
  79. package/dist/lib/other-apps.js +7 -1
  80. package/dist/lib/other-apps.js.map +1 -1
  81. package/dist/lib/workspace-app-layout.d.ts +62 -6
  82. package/dist/lib/workspace-app-layout.d.ts.map +1 -1
  83. package/dist/lib/workspace-app-layout.js +196 -41
  84. package/dist/lib/workspace-app-layout.js.map +1 -1
  85. package/dist/lib/workspace-app-lists.d.ts +17 -0
  86. package/dist/lib/workspace-app-lists.d.ts.map +1 -0
  87. package/dist/lib/workspace-app-lists.js +21 -0
  88. package/dist/lib/workspace-app-lists.js.map +1 -0
  89. package/dist/lib/workspace-apps.d.ts +3 -1
  90. package/dist/lib/workspace-apps.d.ts.map +1 -1
  91. package/dist/lib/workspace-apps.js +5 -1
  92. package/dist/lib/workspace-apps.js.map +1 -1
  93. package/dist/routes/pages/apps.d.ts.map +1 -1
  94. package/dist/routes/pages/apps.js +23 -35
  95. package/dist/routes/pages/apps.js.map +1 -1
  96. package/dist/routes/pages/settings-page.d.ts.map +1 -1
  97. package/dist/routes/pages/settings-page.js +24 -3
  98. package/dist/routes/pages/settings-page.js.map +1 -1
  99. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  100. package/dist/server/lib/app-creation-store.js +13 -18
  101. package/dist/server/lib/app-creation-store.js.map +1 -1
  102. package/dist/server/lib/app-roles.d.ts +2 -0
  103. package/dist/server/lib/app-roles.d.ts.map +1 -1
  104. package/dist/server/lib/app-roles.js +26 -7
  105. package/dist/server/lib/app-roles.js.map +1 -1
  106. package/dist/server/lib/hosted-apps-settings-schema.d.ts +6 -0
  107. package/dist/server/lib/hosted-apps-settings-schema.d.ts.map +1 -0
  108. package/dist/server/lib/hosted-apps-settings-schema.js +6 -0
  109. package/dist/server/lib/hosted-apps-settings-schema.js.map +1 -0
  110. package/dist/server/lib/hosted-apps-settings.d.ts +27 -0
  111. package/dist/server/lib/hosted-apps-settings.d.ts.map +1 -0
  112. package/dist/server/lib/hosted-apps-settings.js +72 -0
  113. package/dist/server/lib/hosted-apps-settings.js.map +1 -0
  114. package/dist/server/plugins/agent-chat.js +6 -3
  115. package/dist/server/plugins/agent-chat.js.map +1 -1
  116. package/dist/server/plugins/integrations.js +3 -3
  117. package/dist/server/plugins/integrations.js.map +1 -1
  118. package/dist/shared/hosted-apps-settings.d.ts +3 -0
  119. package/dist/shared/hosted-apps-settings.d.ts.map +1 -0
  120. package/dist/shared/hosted-apps-settings.js +3 -0
  121. package/dist/shared/hosted-apps-settings.js.map +1 -0
  122. package/package.json +4 -4
  123. package/src/actions/get-hosted-apps-settings.ts +29 -0
  124. package/src/actions/hosted-apps-settings.spec.ts +100 -0
  125. package/src/actions/index.ts +4 -0
  126. package/src/actions/list-connected-agents.spec.ts +67 -0
  127. package/src/actions/list-connected-agents.ts +14 -15
  128. package/src/actions/list_apps.spec.ts +16 -0
  129. package/src/actions/list_apps.ts +5 -2
  130. package/src/actions/set-hosted-apps-settings.ts +42 -0
  131. package/src/components/app-card-tiers.tsx +85 -0
  132. package/src/components/app-list-row.spec.tsx +54 -0
  133. package/src/components/app-list-row.tsx +50 -8
  134. package/src/components/app-open-actions.tsx +16 -0
  135. package/src/components/app-pin-button.spec.tsx +68 -0
  136. package/src/components/app-pin-button.tsx +66 -0
  137. package/src/components/app-tier-sections.spec.tsx +155 -0
  138. package/src/components/app-tier-sections.tsx +158 -0
  139. package/src/components/chat-home-apps.spec.tsx +135 -29
  140. package/src/components/chat-home-apps.tsx +115 -111
  141. package/src/components/connected-app-card.spec.tsx +18 -2
  142. package/src/components/connected-app-card.tsx +18 -1
  143. package/src/components/create-app-popover.tsx +22 -0
  144. package/src/components/dispatch-control-plane.spec.tsx +89 -20
  145. package/src/components/dispatch-control-plane.tsx +91 -125
  146. package/src/components/hosted-apps-settings-group.spec.tsx +218 -0
  147. package/src/components/hosted-apps-settings-group.tsx +189 -0
  148. package/src/components/layout/Layout.spec.tsx +356 -49
  149. package/src/components/layout/Layout.tsx +189 -142
  150. package/src/components/workspace-app-card.spec.tsx +33 -29
  151. package/src/components/workspace-app-card.tsx +13 -31
  152. package/src/components/workspace-app-layout-notice.spec.tsx +106 -0
  153. package/src/components/workspace-app-layout-notice.tsx +65 -0
  154. package/src/components/workspace-app-search.spec.tsx +53 -0
  155. package/src/components/workspace-app-search.tsx +39 -32
  156. package/src/components/workspace-template-card.spec.tsx +10 -0
  157. package/src/components/workspace-template-card.tsx +7 -2
  158. package/src/db/index.spec.ts +9 -0
  159. package/src/db/index.ts +1 -0
  160. package/src/lib/other-apps.spec.ts +25 -0
  161. package/src/lib/other-apps.ts +9 -0
  162. package/src/lib/workspace-app-layout.spec.ts +61 -3
  163. package/src/lib/workspace-app-layout.store.spec.tsx +261 -0
  164. package/src/lib/workspace-app-layout.ts +265 -48
  165. package/src/lib/workspace-app-lists.ts +32 -0
  166. package/src/lib/workspace-apps.ts +10 -2
  167. package/src/routes/pages/apps.spec.tsx +133 -5
  168. package/src/routes/pages/apps.tsx +79 -140
  169. package/src/routes/pages/chat.spec.tsx +66 -49
  170. package/src/routes/pages/settings-page.tsx +31 -3
  171. package/src/routes/pages/settings.spec.tsx +37 -0
  172. package/src/server/lib/app-creation-store.spec.ts +14 -0
  173. package/src/server/lib/app-creation-store.ts +16 -29
  174. package/src/server/lib/app-creation-store.write-observation.spec.ts +216 -0
  175. package/src/server/lib/app-roles.spec.ts +48 -1
  176. package/src/server/lib/app-roles.ts +44 -12
  177. package/src/server/lib/hosted-apps-settings-schema.ts +6 -0
  178. package/src/server/lib/hosted-apps-settings.spec.ts +154 -0
  179. package/src/server/lib/hosted-apps-settings.ts +108 -0
  180. package/src/server/plugins/agent-chat.spec.ts +16 -0
  181. package/src/server/plugins/agent-chat.ts +7 -2
  182. package/src/server/plugins/integrations.ts +3 -3
  183. package/src/shared/hosted-apps-settings.ts +3 -0
  184. package/src/styles/dispatch.css +40 -11
  185. package/dist/components/available-apps-section.d.ts +0 -9
  186. package/dist/components/available-apps-section.d.ts.map +0 -1
  187. package/dist/components/available-apps-section.js +0 -97
  188. package/dist/components/available-apps-section.js.map +0 -1
  189. package/dist/components/other-apps-section.d.ts +0 -35
  190. package/dist/components/other-apps-section.d.ts.map +0 -1
  191. package/dist/components/other-apps-section.js +0 -79
  192. package/dist/components/other-apps-section.js.map +0 -1
  193. package/src/components/available-apps-section.tsx +0 -239
  194. package/src/components/other-apps-section.spec.ts +0 -50
  195. package/src/components/other-apps-section.tsx +0 -254
@@ -0,0 +1,155 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { MemoryRouter } from "react-router";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ vi.mock("@agent-native/core/client/i18n", () => ({
8
+ useT: () => (key: string) => key,
9
+ }));
10
+
11
+ const orgRole = vi.hoisted(() => ({
12
+ current: {
13
+ org: { orgId: "org-1" } as { orgId: string | null } | null,
14
+ canManageOrg: true,
15
+ isLoading: false,
16
+ },
17
+ }));
18
+
19
+ vi.mock("@agent-native/core/client/org", () => ({
20
+ useOrgRole: () => orgRole.current,
21
+ }));
22
+
23
+ import { AppListActions, AppTierSections } from "./app-tier-sections";
24
+ import { TooltipProvider } from "./ui/tooltip";
25
+
26
+ describe("AppTierSections", () => {
27
+ let container: HTMLDivElement;
28
+ let root: Root;
29
+
30
+ beforeEach(() => {
31
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
32
+ container = document.createElement("div");
33
+ document.body.appendChild(container);
34
+ root = createRoot(container);
35
+ });
36
+
37
+ afterEach(() => {
38
+ orgRole.current = {
39
+ org: { orgId: "org-1" },
40
+ canManageOrg: true,
41
+ isLoading: false,
42
+ };
43
+ act(() => root.unmount());
44
+ container.remove();
45
+ vi.unstubAllGlobals();
46
+ });
47
+
48
+ it.each([
49
+ ["nothing is pinned", [], false, "Apps"],
50
+ ["an app is pinned", ["mail"], true, "dispatch.pages.pinnedApps"],
51
+ // A search that matches no pinned app keeps the input where it was.
52
+ ["no pinned app matches the search", [], true, "dispatch.pages.pinnedApps"],
53
+ ])(
54
+ "puts View all, search and New app together in the first header when %s",
55
+ async (_case, pinned, hasPinnedApps, header) => {
56
+ await act(async () =>
57
+ root.render(
58
+ <MemoryRouter>
59
+ <AppTierSections
60
+ tiers={{ pinned, apps: ["plan"], hosted: [] }}
61
+ hasPinnedApps={hasPinnedApps}
62
+ appsTitle="Apps"
63
+ actions={
64
+ <AppListActions
65
+ viewAllHref="/apps"
66
+ search={<input data-search />}
67
+ newApp={<button type="button" data-new-app />}
68
+ />
69
+ }
70
+ renderItems={(items) => items.join(",")}
71
+ />
72
+ </MemoryRouter>,
73
+ ),
74
+ );
75
+
76
+ const search = container.querySelectorAll("[data-search]");
77
+ expect(search).toHaveLength(1);
78
+ const section = search[0]!.closest("section");
79
+ expect(section?.getAttribute("aria-label")).toBe(header);
80
+ const cluster = search[0]!.closest("[data-app-list-actions]")!;
81
+ expect(
82
+ [...cluster.children].map((child) =>
83
+ child.matches("a")
84
+ ? `link:${child.getAttribute("href")}:${child.textContent}`
85
+ : child.querySelector("[data-search]")
86
+ ? "search"
87
+ : "new",
88
+ ),
89
+ ).toEqual(["link:/apps:dispatch.pages.viewAllApps", "search", "new"]);
90
+ expect(container.querySelectorAll("[data-new-app]")).toHaveLength(1);
91
+ },
92
+ );
93
+
94
+ it("lets a wide header action shrink into the row instead of overflowing", async () => {
95
+ await act(async () =>
96
+ root.render(
97
+ <MemoryRouter>
98
+ <AppTierSections
99
+ tiers={{ pinned: [], apps: [], hosted: [] }}
100
+ appsTitle="Apps"
101
+ actions={<AppListActions newApp={<div data-apps-action />} />}
102
+ renderItems={() => null}
103
+ />
104
+ </MemoryRouter>,
105
+ ),
106
+ );
107
+
108
+ const action =
109
+ container.querySelector("[data-apps-action]")!.parentElement!;
110
+ const classes = action.className.split(" ");
111
+ expect(classes).not.toContain("shrink-0");
112
+ expect(classes).toEqual(expect.arrayContaining(["min-w-0", "flex-1"]));
113
+ });
114
+
115
+ it.each([
116
+ ["an admin", { orgId: "org-1" }, true, "link"],
117
+ ["a member", { orgId: "org-1" }, false, "disabled"],
118
+ ["a solo workspace", null, true, "none"],
119
+ ["a user with no organization", { orgId: null }, true, "none"],
120
+ ] as const)(
121
+ "shows Manage on the Hosted apps tier as expected for %s",
122
+ async (_who, org, canManageOrg, expected) => {
123
+ orgRole.current = { org, canManageOrg, isLoading: false };
124
+ await act(async () =>
125
+ root.render(
126
+ <MemoryRouter>
127
+ <TooltipProvider>
128
+ <AppTierSections
129
+ tiers={{ pinned: [], apps: [], hosted: ["clips"] }}
130
+ appsTitle="Apps"
131
+ renderItems={(items) => items.join(",")}
132
+ />
133
+ </TooltipProvider>
134
+ </MemoryRouter>,
135
+ ),
136
+ );
137
+
138
+ const hosted = container.querySelector(
139
+ 'section[aria-label="dispatch.pages.hostedApps"]',
140
+ )!;
141
+ const manage = [...hosted.querySelectorAll("a, button")].find(
142
+ (element) => element.textContent === "dispatch.pages.manageHostedApps",
143
+ );
144
+ const shown =
145
+ manage === undefined
146
+ ? "none"
147
+ : manage.tagName === "A"
148
+ ? "link"
149
+ : (manage as HTMLButtonElement).disabled
150
+ ? "disabled"
151
+ : "enabled";
152
+ expect(shown).toBe(expected);
153
+ },
154
+ );
155
+ });
@@ -0,0 +1,158 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import { useOrgRole } from "@agent-native/core/client/org";
3
+ import { buildSettingsRoute } from "@agent-native/core/navigation";
4
+ import type { ReactNode } from "react";
5
+ import { Link } from "react-router";
6
+
7
+ import { cn } from "../lib/utils";
8
+ import type { WorkspaceAppTiers } from "../lib/workspace-app-layout";
9
+ import { Button } from "./ui/button";
10
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
11
+
12
+ function TierHeader({ title, action }: { title: string; action?: ReactNode }) {
13
+ return (
14
+ <div className="flex min-w-0 items-center justify-between gap-3">
15
+ <h2 className="truncate text-sm font-semibold text-foreground">
16
+ {title}
17
+ </h2>
18
+ {/* Takes the rest of the row so a wide action wraps inside it. */}
19
+ {action ? (
20
+ <div className="flex min-w-0 flex-1 justify-end">{action}</div>
21
+ ) : null}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ /** The Apps tier's empty text when the workspace has no apps of its own. */
27
+ export function NoWorkspaceAppsText() {
28
+ const t = useT();
29
+ return (
30
+ <p className="px-4 py-3 text-sm text-muted-foreground">
31
+ {t("dispatch.pages.chatFirstNoWorkspaceApps")}
32
+ </p>
33
+ );
34
+ }
35
+
36
+ function ManageHostedAppsLink() {
37
+ const t = useT();
38
+ const { org, canManageOrg, isLoading } = useOrgRole();
39
+ const label = t("dispatch.pages.manageHostedApps");
40
+ // Settings › Organization › Apps only exists inside an organization.
41
+ if (isLoading || !org?.orgId) return null;
42
+ if (canManageOrg) {
43
+ return (
44
+ <Button variant="ghost" size="sm" asChild>
45
+ <Link to={buildSettingsRoute("apps")} className="text-muted-foreground">
46
+ {label}
47
+ </Link>
48
+ </Button>
49
+ );
50
+ }
51
+ return (
52
+ <Tooltip>
53
+ <TooltipTrigger asChild>
54
+ <span tabIndex={0} className="inline-flex rounded-md">
55
+ <Button variant="ghost" size="sm" disabled>
56
+ {label}
57
+ </Button>
58
+ </span>
59
+ </TooltipTrigger>
60
+ <TooltipContent>
61
+ {t("dispatch.pages.manageHostedAppsAdminOnly")}
62
+ </TooltipContent>
63
+ </Tooltip>
64
+ );
65
+ }
66
+
67
+ /** View all, search and New app: the header cluster of every app list. */
68
+ export function AppListActions({
69
+ viewAllHref,
70
+ search,
71
+ newApp,
72
+ }: {
73
+ viewAllHref?: string | undefined;
74
+ search?: ReactNode;
75
+ newApp?: ReactNode;
76
+ }) {
77
+ const t = useT();
78
+ return (
79
+ // Below `sm` the search takes its own full-width line under the buttons.
80
+ <div
81
+ data-app-list-actions
82
+ className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-1.5 sm:flex-nowrap"
83
+ >
84
+ {viewAllHref ? (
85
+ <Button variant="ghost" size="sm" asChild>
86
+ <Link to={viewAllHref} className="text-muted-foreground">
87
+ {t("dispatch.pages.viewAllApps")}
88
+ </Link>
89
+ </Button>
90
+ ) : null}
91
+ {search ? (
92
+ <div className="order-last min-w-0 basis-full sm:order-none sm:basis-auto">
93
+ {search}
94
+ </div>
95
+ ) : null}
96
+ {newApp}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Pinned (only when something is pinned), Apps, then Hosted apps. The Apps
103
+ * tier always renders. `actions` sit in the first header; `hasPinnedApps` is
104
+ * decided before a search filters, so a query that matches no pinned app does
105
+ * not move the search while typing.
106
+ */
107
+ export function AppTierSections<T>({
108
+ tiers,
109
+ appsTitle,
110
+ actions,
111
+ hasPinnedApps = tiers.pinned.length > 0,
112
+ appsFallback,
113
+ renderItems,
114
+ className,
115
+ }: {
116
+ tiers: WorkspaceAppTiers<T>;
117
+ appsTitle: string;
118
+ actions?: ReactNode;
119
+ hasPinnedApps?: boolean | undefined;
120
+ /** Shown in the Apps tier when it has no rows (loading, empty, no matches). */
121
+ appsFallback?: ReactNode;
122
+ renderItems: (items: T[]) => ReactNode;
123
+ className?: string;
124
+ }) {
125
+ const t = useT();
126
+ return (
127
+ <div className={cn("flex flex-col gap-6", className)}>
128
+ {hasPinnedApps ? (
129
+ <section
130
+ aria-label={t("dispatch.pages.pinnedApps")}
131
+ className="flex flex-col gap-3"
132
+ >
133
+ <TierHeader title={t("dispatch.pages.pinnedApps")} action={actions} />
134
+ {tiers.pinned.length > 0 ? renderItems(tiers.pinned) : null}
135
+ </section>
136
+ ) : null}
137
+ <section aria-label={appsTitle} className="flex flex-col gap-3">
138
+ <TierHeader
139
+ title={appsTitle}
140
+ action={hasPinnedApps ? undefined : actions}
141
+ />
142
+ {tiers.apps.length > 0 ? renderItems(tiers.apps) : appsFallback}
143
+ </section>
144
+ {tiers.hosted.length > 0 ? (
145
+ <section
146
+ aria-label={t("dispatch.pages.hostedApps")}
147
+ className="flex flex-col gap-3"
148
+ >
149
+ <TierHeader
150
+ title={t("dispatch.pages.hostedApps")}
151
+ action={<ManageHostedAppsLink />}
152
+ />
153
+ {renderItems(tiers.hosted)}
154
+ </section>
155
+ ) : null}
156
+ </div>
157
+ );
158
+ }
@@ -24,7 +24,7 @@ const launcherState = vi.hoisted(() => ({
24
24
  path: "/mail",
25
25
  },
26
26
  ],
27
- isLoading: false,
27
+ workspaceAppsStatus: "ready" as "loading" | "error" | "ready",
28
28
  error: undefined as unknown,
29
29
  errorQueryName: undefined as
30
30
  | "list-workspace-apps"
@@ -36,6 +36,7 @@ const launcherState = vi.hoisted(() => ({
36
36
  }));
37
37
  const appLayoutState = vi.hoisted(() => ({
38
38
  value: { pinnedIds: ["mail"], orderedIds: ["mail", "plan"] },
39
+ togglePinned: vi.fn(),
39
40
  }));
40
41
 
41
42
  vi.mock("@agent-native/core/client/i18n", () => ({
@@ -43,7 +44,6 @@ vi.mock("@agent-native/core/client/i18n", () => ({
43
44
  ({
44
45
  "dispatch.nav.apps": "Apps",
45
46
  "dispatch.pages.allApps": "All apps",
46
- "dispatch.pages.chatFirstNewApp": "New",
47
47
  "dispatch.pages.openApp": "Open",
48
48
  "dispatch.pages.chatFirstOpenApp": "Open {{name}}",
49
49
  "dispatch.pages.chatFirstOpenInNewTab": "Open in new tab",
@@ -52,9 +52,25 @@ vi.mock("@agent-native/core/client/i18n", () => ({
52
52
  "extensions.optionsFor": "Options for {{name}}",
53
53
  "dispatch.pages.searchApps": "Search apps",
54
54
  "dispatch.pages.searchAppsPlaceholder": "Search apps",
55
+ "dispatch.pages.hostedApps": "Hosted apps",
56
+ "dispatch.pages.pinApp": "Pin {{name}}",
57
+ "dispatch.pages.unpinApp": "Unpin {{name}}",
58
+ "dispatch.pages.pinnedApps": "Pinned apps",
59
+ "dispatch.pages.workspaceApps": "Workspace apps",
60
+ "dispatch.pages.viewAllApps": "View all",
61
+ "dispatch.pages.newApp": "New app",
55
62
  })[key]?.replace("{{name}}", values?.name ?? "") ?? key,
56
63
  }));
57
64
 
65
+ vi.mock("@agent-native/core/client/org", async (importOriginal) => ({
66
+ ...(await importOriginal<typeof import("@agent-native/core/client/org")>()),
67
+ useOrgRole: () => ({
68
+ org: { orgId: "org-1" },
69
+ canManageOrg: true,
70
+ isLoading: false,
71
+ }),
72
+ }));
73
+
58
74
  vi.mock("./layout/Layout", () => ({
59
75
  dispatchNavLinkTarget: (path: string) => path,
60
76
  useDispatchWorkspaceAppLauncher: () => launcherState.value,
@@ -65,11 +81,25 @@ vi.mock("../lib/workspace-app-layout", async (importOriginal) => {
65
81
  await importOriginal<typeof import("../lib/workspace-app-layout")>();
66
82
  return {
67
83
  ...actual,
68
- useWorkspaceAppLayout: () => ({ layout: appLayoutState.value }),
84
+ useWorkspaceAppLayout: () => ({
85
+ layout: appLayoutState.value,
86
+ togglePinned: appLayoutState.togglePinned,
87
+ }),
69
88
  };
70
89
  });
71
90
 
72
91
  import { DispatchChatHomeApps } from "./chat-home-apps";
92
+ import { TooltipProvider } from "./ui/tooltip";
93
+
94
+ function renderChatHome(root: Root) {
95
+ root.render(
96
+ <MemoryRouter initialEntries={["/chat"]}>
97
+ <TooltipProvider>
98
+ <DispatchChatHomeApps />
99
+ </TooltipProvider>
100
+ </MemoryRouter>,
101
+ );
102
+ }
73
103
 
74
104
  describe("DispatchChatHomeApps", () => {
75
105
  let container: HTMLDivElement;
@@ -92,7 +122,13 @@ describe("DispatchChatHomeApps", () => {
92
122
  },
93
123
  ];
94
124
  launcherState.value.openApp.mockReset();
125
+ appLayoutState.togglePinned.mockReset();
126
+ appLayoutState.value = {
127
+ pinnedIds: ["mail"],
128
+ orderedIds: ["mail", "plan"],
129
+ };
95
130
  launcherState.value.retry.mockReset();
131
+ launcherState.value.workspaceAppsStatus = "ready";
96
132
  launcherState.value.error = undefined;
97
133
  launcherState.value.errorQueryName = undefined;
98
134
  container = document.createElement("div");
@@ -108,20 +144,19 @@ describe("DispatchChatHomeApps", () => {
108
144
 
109
145
  it("renders the searchable Electron-style app directory and open actions", async () => {
110
146
  await act(async () => {
111
- root.render(
112
- <MemoryRouter initialEntries={["/chat"]}>
113
- <DispatchChatHomeApps />
114
- </MemoryRouter>,
115
- );
147
+ renderChatHome(root);
116
148
  });
117
149
 
118
- const section = container.querySelector("section[aria-label='Apps']");
150
+ const section = container.querySelector(
151
+ "section[aria-label='Workspace apps']",
152
+ );
119
153
  const grid = section?.querySelector(".grid.grid-cols-1");
120
154
  expect(grid?.className.split(" ")).toContain("sm:grid-cols-2");
121
- expect(section?.querySelectorAll("article")).toHaveLength(2);
122
- expect(section?.querySelectorAll("article")[0]?.textContent).toContain(
123
- "Mail",
124
- );
155
+ expect(section?.querySelectorAll("article")).toHaveLength(1);
156
+ expect(section?.textContent).toContain("Plan");
157
+ expect(
158
+ container.querySelector("section[aria-label='Pinned apps']")?.textContent,
159
+ ).toContain("Mail");
125
160
  expect(container.textContent).toContain("Structured project plans");
126
161
  expect(
127
162
  container.querySelector("button[aria-label='Options for Mail']"),
@@ -140,16 +175,14 @@ describe("DispatchChatHomeApps", () => {
140
175
 
141
176
  const allAppsLink =
142
177
  container.querySelector<HTMLAnchorElement>("a[href='/apps']");
143
- expect(allAppsLink?.textContent?.trim()).toContain("All apps");
178
+ expect(allAppsLink?.textContent?.trim()).toBe("View all");
179
+ // View all sits in the header cluster, not below the list.
180
+ expect(allAppsLink?.closest("section[aria-label]")).not.toBeNull();
144
181
  });
145
182
 
146
183
  it("filters app descriptions and opens the selected app", async () => {
147
184
  await act(async () => {
148
- root.render(
149
- <MemoryRouter initialEntries={["/chat"]}>
150
- <DispatchChatHomeApps />
151
- </MemoryRouter>,
152
- );
185
+ renderChatHome(root);
153
186
  });
154
187
 
155
188
  const search = container.querySelector<HTMLInputElement>(
@@ -190,17 +223,94 @@ describe("DispatchChatHomeApps", () => {
190
223
  ];
191
224
 
192
225
  await act(async () => {
193
- root.render(
194
- <MemoryRouter initialEntries={["/chat"]}>
195
- <DispatchChatHomeApps />
196
- </MemoryRouter>,
197
- );
226
+ renderChatHome(root);
198
227
  });
199
228
 
200
229
  expect(container.textContent).toContain("Localized Calendar description");
201
230
  expect(container.textContent).not.toContain("Default English text");
202
231
  });
203
232
 
233
+ it("tiers pinned, workspace and hosted apps with a pin button on each row", async () => {
234
+ launcherState.value.workspaceApps = [
235
+ { id: "zeta", name: "Zeta", path: "/zeta" },
236
+ { id: "mail", name: "Mail", source: "builtin", url: "https://m.test" },
237
+ { id: "alpha", name: "Alpha", path: "/alpha" },
238
+ { id: "plan", name: "Plan", path: "/plan" },
239
+ { id: "clips", name: "Clips", source: "builtin", url: "https://c.test" },
240
+ ] as typeof launcherState.value.workspaceApps;
241
+ appLayoutState.value = { pinnedIds: ["plan", "mail"], orderedIds: [] };
242
+
243
+ await act(async () => renderChatHome(root));
244
+
245
+ const names = (label: string) =>
246
+ [
247
+ ...container.querySelectorAll(
248
+ `section[aria-label='${label}'] article .font-semibold`,
249
+ ),
250
+ ].map((node) => node.textContent);
251
+ expect(names("Pinned apps")).toEqual(["Plan", "Mail"]);
252
+ expect(names("Workspace apps")).toEqual(["Alpha", "Zeta"]);
253
+ expect(names("Hosted apps")).toEqual(["Clips"]);
254
+ expect(
255
+ container.querySelector("section[aria-label='Hosted apps'] a")
256
+ ?.textContent,
257
+ ).toBe("dispatch.pages.manageHostedApps");
258
+
259
+ const unpinMail = container.querySelector<HTMLButtonElement>(
260
+ "button[aria-label='Unpin Mail']",
261
+ );
262
+ expect(unpinMail?.getAttribute("aria-pressed")).toBe("true");
263
+ const pinClips = container.querySelector<HTMLButtonElement>(
264
+ "button[aria-label='Pin Clips']",
265
+ );
266
+ expect(pinClips?.getAttribute("aria-pressed")).toBe("false");
267
+ await act(async () => pinClips?.click());
268
+ expect(appLayoutState.togglePinned).toHaveBeenCalledWith("clips");
269
+ expect(launcherState.value.openApp).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("says the workspace has no apps under Apps when only hosted apps exist", async () => {
273
+ launcherState.value.workspaceApps = [
274
+ { id: "mail", name: "Mail", source: "builtin", url: "https://m.test" },
275
+ ] as typeof launcherState.value.workspaceApps;
276
+ appLayoutState.value = { pinnedIds: [], orderedIds: [] };
277
+
278
+ await act(async () => renderChatHome(root));
279
+
280
+ expect(
281
+ container.querySelector("section[aria-label='Workspace apps']")
282
+ ?.textContent,
283
+ ).toContain("dispatch.pages.chatFirstNoWorkspaceApps");
284
+ expect(
285
+ container.querySelectorAll("section[aria-label='Hosted apps'] article"),
286
+ ).toHaveLength(1);
287
+ });
288
+
289
+ it("does not claim there are no apps while workspace apps load", async () => {
290
+ launcherState.value.workspaceApps = [];
291
+ launcherState.value.workspaceAppsStatus = "loading";
292
+
293
+ await act(async () => renderChatHome(root));
294
+
295
+ expect(container.textContent).not.toContain(
296
+ "dispatch.pages.chatFirstNoWorkspaceApps",
297
+ );
298
+ });
299
+
300
+ it("keeps the Apps header and New app trigger with no apps at all", async () => {
301
+ launcherState.value.workspaceApps = [];
302
+
303
+ await act(async () => renderChatHome(root));
304
+
305
+ const apps = container.querySelector(
306
+ "section[aria-label='Workspace apps']",
307
+ );
308
+ expect(apps?.textContent).toContain("New");
309
+ expect(apps?.textContent).toContain(
310
+ "dispatch.pages.chatFirstNoWorkspaceApps",
311
+ );
312
+ });
313
+
204
314
  it.each([
205
315
  ["list-workspace-apps", "Workspace apps failed"],
206
316
  ["list-connected-agents", "Connected agents failed"],
@@ -213,11 +323,7 @@ describe("DispatchChatHomeApps", () => {
213
323
  launcherState.value.errorQueryName = queryName;
214
324
 
215
325
  await act(async () => {
216
- root.render(
217
- <MemoryRouter initialEntries={["/chat"]}>
218
- <DispatchChatHomeApps />
219
- </MemoryRouter>,
220
- );
326
+ renderChatHome(root);
221
327
  });
222
328
 
223
329
  expect(container.textContent).toContain(