@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.
- package/CHANGELOG.md +26 -55
- package/dist/actions/get-hosted-apps-settings.d.ts +5 -0
- package/dist/actions/get-hosted-apps-settings.d.ts.map +1 -0
- package/dist/actions/get-hosted-apps-settings.js +25 -0
- package/dist/actions/get-hosted-apps-settings.js.map +1 -0
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +4 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/list-connected-agents.d.ts +1 -0
- package/dist/actions/list-connected-agents.js +11 -10
- package/dist/actions/list-connected-agents.js.map +1 -1
- package/dist/actions/list_apps.js +3 -2
- package/dist/actions/list_apps.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +10 -10
- package/dist/actions/set-hosted-apps-settings.d.ts +7 -0
- package/dist/actions/set-hosted-apps-settings.d.ts.map +1 -0
- package/dist/actions/set-hosted-apps-settings.js +35 -0
- package/dist/actions/set-hosted-apps-settings.js.map +1 -0
- package/dist/components/app-card-tiers.d.ts +13 -0
- package/dist/components/app-card-tiers.d.ts.map +1 -0
- package/dist/components/app-card-tiers.js +22 -0
- package/dist/components/app-card-tiers.js.map +1 -0
- package/dist/components/app-list-row.d.ts +12 -4
- package/dist/components/app-list-row.d.ts.map +1 -1
- package/dist/components/app-list-row.js +19 -4
- package/dist/components/app-list-row.js.map +1 -1
- package/dist/components/app-open-actions.d.ts +4 -0
- package/dist/components/app-open-actions.d.ts.map +1 -1
- package/dist/components/app-open-actions.js +9 -0
- package/dist/components/app-open-actions.js.map +1 -1
- package/dist/components/app-pin-button.d.ts +9 -0
- package/dist/components/app-pin-button.d.ts.map +1 -0
- package/dist/components/app-pin-button.js +29 -0
- package/dist/components/app-pin-button.js.map +1 -0
- package/dist/components/app-tier-sections.d.ts +27 -0
- package/dist/components/app-tier-sections.d.ts.map +1 -0
- package/dist/components/app-tier-sections.js +44 -0
- package/dist/components/app-tier-sections.js.map +1 -0
- package/dist/components/chat-home-apps.d.ts.map +1 -1
- package/dist/components/chat-home-apps.js +19 -26
- package/dist/components/chat-home-apps.js.map +1 -1
- package/dist/components/connected-app-card.d.ts +3 -1
- package/dist/components/connected-app-card.d.ts.map +1 -1
- package/dist/components/connected-app-card.js +6 -3
- package/dist/components/connected-app-card.js.map +1 -1
- package/dist/components/create-app-popover.d.ts +4 -0
- package/dist/components/create-app-popover.d.ts.map +1 -1
- package/dist/components/create-app-popover.js +6 -0
- package/dist/components/create-app-popover.js.map +1 -1
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +25 -35
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/hosted-apps-settings-group.d.ts +6 -0
- package/dist/components/hosted-apps-settings-group.d.ts.map +1 -0
- package/dist/components/hosted-apps-settings-group.js +76 -0
- package/dist/components/hosted-apps-settings-group.js.map +1 -0
- package/dist/components/layout/Layout.d.ts +6 -4
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +97 -80
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +9 -20
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-app-layout-notice.d.ts +9 -0
- package/dist/components/workspace-app-layout-notice.d.ts.map +1 -0
- package/dist/components/workspace-app-layout-notice.js +27 -0
- package/dist/components/workspace-app-layout-notice.js.map +1 -0
- package/dist/components/workspace-app-search.d.ts.map +1 -1
- package/dist/components/workspace-app-search.js +11 -5
- package/dist/components/workspace-app-search.js.map +1 -1
- package/dist/components/workspace-template-card.d.ts.map +1 -1
- package/dist/components/workspace-template-card.js +7 -2
- package/dist/components/workspace-template-card.js.map +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -0
- package/dist/db/index.js.map +1 -1
- package/dist/lib/other-apps.d.ts +10 -1
- package/dist/lib/other-apps.d.ts.map +1 -1
- package/dist/lib/other-apps.js +7 -1
- package/dist/lib/other-apps.js.map +1 -1
- package/dist/lib/workspace-app-layout.d.ts +62 -6
- package/dist/lib/workspace-app-layout.d.ts.map +1 -1
- package/dist/lib/workspace-app-layout.js +196 -41
- package/dist/lib/workspace-app-layout.js.map +1 -1
- package/dist/lib/workspace-app-lists.d.ts +17 -0
- package/dist/lib/workspace-app-lists.d.ts.map +1 -0
- package/dist/lib/workspace-app-lists.js +21 -0
- package/dist/lib/workspace-app-lists.js.map +1 -0
- package/dist/lib/workspace-apps.d.ts +3 -1
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js +5 -1
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +23 -35
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/settings-page.d.ts.map +1 -1
- package/dist/routes/pages/settings-page.js +24 -3
- package/dist/routes/pages/settings-page.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +13 -18
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/app-roles.d.ts +2 -0
- package/dist/server/lib/app-roles.d.ts.map +1 -1
- package/dist/server/lib/app-roles.js +26 -7
- package/dist/server/lib/app-roles.js.map +1 -1
- package/dist/server/lib/hosted-apps-settings-schema.d.ts +6 -0
- package/dist/server/lib/hosted-apps-settings-schema.d.ts.map +1 -0
- package/dist/server/lib/hosted-apps-settings-schema.js +6 -0
- package/dist/server/lib/hosted-apps-settings-schema.js.map +1 -0
- package/dist/server/lib/hosted-apps-settings.d.ts +27 -0
- package/dist/server/lib/hosted-apps-settings.d.ts.map +1 -0
- package/dist/server/lib/hosted-apps-settings.js +72 -0
- package/dist/server/lib/hosted-apps-settings.js.map +1 -0
- package/dist/server/plugins/agent-chat.js +6 -3
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/dist/server/plugins/integrations.js +3 -3
- package/dist/server/plugins/integrations.js.map +1 -1
- package/dist/shared/hosted-apps-settings.d.ts +3 -0
- package/dist/shared/hosted-apps-settings.d.ts.map +1 -0
- package/dist/shared/hosted-apps-settings.js +3 -0
- package/dist/shared/hosted-apps-settings.js.map +1 -0
- package/package.json +4 -4
- package/src/actions/get-hosted-apps-settings.ts +29 -0
- package/src/actions/hosted-apps-settings.spec.ts +100 -0
- package/src/actions/index.ts +4 -0
- package/src/actions/list-connected-agents.spec.ts +67 -0
- package/src/actions/list-connected-agents.ts +14 -15
- package/src/actions/list_apps.spec.ts +16 -0
- package/src/actions/list_apps.ts +5 -2
- package/src/actions/set-hosted-apps-settings.ts +42 -0
- package/src/components/app-card-tiers.tsx +85 -0
- package/src/components/app-list-row.spec.tsx +54 -0
- package/src/components/app-list-row.tsx +50 -8
- package/src/components/app-open-actions.tsx +16 -0
- package/src/components/app-pin-button.spec.tsx +68 -0
- package/src/components/app-pin-button.tsx +66 -0
- package/src/components/app-tier-sections.spec.tsx +155 -0
- package/src/components/app-tier-sections.tsx +158 -0
- package/src/components/chat-home-apps.spec.tsx +135 -29
- package/src/components/chat-home-apps.tsx +115 -111
- package/src/components/connected-app-card.spec.tsx +18 -2
- package/src/components/connected-app-card.tsx +18 -1
- package/src/components/create-app-popover.tsx +22 -0
- package/src/components/dispatch-control-plane.spec.tsx +89 -20
- package/src/components/dispatch-control-plane.tsx +91 -125
- package/src/components/hosted-apps-settings-group.spec.tsx +218 -0
- package/src/components/hosted-apps-settings-group.tsx +189 -0
- package/src/components/layout/Layout.spec.tsx +356 -49
- package/src/components/layout/Layout.tsx +189 -142
- package/src/components/workspace-app-card.spec.tsx +33 -29
- package/src/components/workspace-app-card.tsx +13 -31
- package/src/components/workspace-app-layout-notice.spec.tsx +106 -0
- package/src/components/workspace-app-layout-notice.tsx +65 -0
- package/src/components/workspace-app-search.spec.tsx +53 -0
- package/src/components/workspace-app-search.tsx +39 -32
- package/src/components/workspace-template-card.spec.tsx +10 -0
- package/src/components/workspace-template-card.tsx +7 -2
- package/src/db/index.spec.ts +9 -0
- package/src/db/index.ts +1 -0
- package/src/lib/other-apps.spec.ts +25 -0
- package/src/lib/other-apps.ts +9 -0
- package/src/lib/workspace-app-layout.spec.ts +61 -3
- package/src/lib/workspace-app-layout.store.spec.tsx +261 -0
- package/src/lib/workspace-app-layout.ts +265 -48
- package/src/lib/workspace-app-lists.ts +32 -0
- package/src/lib/workspace-apps.ts +10 -2
- package/src/routes/pages/apps.spec.tsx +133 -5
- package/src/routes/pages/apps.tsx +79 -140
- package/src/routes/pages/chat.spec.tsx +66 -49
- package/src/routes/pages/settings-page.tsx +31 -3
- package/src/routes/pages/settings.spec.tsx +37 -0
- package/src/server/lib/app-creation-store.spec.ts +14 -0
- package/src/server/lib/app-creation-store.ts +16 -29
- package/src/server/lib/app-creation-store.write-observation.spec.ts +216 -0
- package/src/server/lib/app-roles.spec.ts +48 -1
- package/src/server/lib/app-roles.ts +44 -12
- package/src/server/lib/hosted-apps-settings-schema.ts +6 -0
- package/src/server/lib/hosted-apps-settings.spec.ts +154 -0
- package/src/server/lib/hosted-apps-settings.ts +108 -0
- package/src/server/plugins/agent-chat.spec.ts +16 -0
- package/src/server/plugins/agent-chat.ts +7 -2
- package/src/server/plugins/integrations.ts +3 -3
- package/src/shared/hosted-apps-settings.ts +3 -0
- package/src/styles/dispatch.css +40 -11
- package/dist/components/available-apps-section.d.ts +0 -9
- package/dist/components/available-apps-section.d.ts.map +0 -1
- package/dist/components/available-apps-section.js +0 -97
- package/dist/components/available-apps-section.js.map +0 -1
- package/dist/components/other-apps-section.d.ts +0 -35
- package/dist/components/other-apps-section.d.ts.map +0 -1
- package/dist/components/other-apps-section.js +0 -79
- package/dist/components/other-apps-section.js.map +0 -1
- package/src/components/available-apps-section.tsx +0 -239
- package/src/components/other-apps-section.spec.ts +0 -50
- 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
|
-
|
|
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: () => ({
|
|
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
|
|
112
|
-
<MemoryRouter initialEntries={["/chat"]}>
|
|
113
|
-
<DispatchChatHomeApps />
|
|
114
|
-
</MemoryRouter>,
|
|
115
|
-
);
|
|
147
|
+
renderChatHome(root);
|
|
116
148
|
});
|
|
117
149
|
|
|
118
|
-
const section = container.querySelector(
|
|
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(
|
|
122
|
-
expect(section?.
|
|
123
|
-
|
|
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()).
|
|
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
|
|
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
|
|
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
|
|
217
|
-
<MemoryRouter initialEntries={["/chat"]}>
|
|
218
|
-
<DispatchChatHomeApps />
|
|
219
|
-
</MemoryRouter>,
|
|
220
|
-
);
|
|
326
|
+
renderChatHome(root);
|
|
221
327
|
});
|
|
222
328
|
|
|
223
329
|
expect(container.textContent).toContain(
|