@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
@@ -16,7 +16,6 @@ import {
16
16
  IconEyeOff,
17
17
  IconFileText,
18
18
  IconKey,
19
- IconPin,
20
19
  IconShare3,
21
20
  IconSettings,
22
21
  IconTrash,
@@ -44,7 +43,8 @@ import { ActionQueryError } from "./action-query-error";
44
43
  import { AppIcon } from "./app-icon";
45
44
  import { AppKeysPanel } from "./app-keys-popover";
46
45
  import { AppListRow } from "./app-list-row";
47
- import { AppOpenActions } from "./app-open-actions";
46
+ import { AppOpenActions, appOpenActionLabels } from "./app-open-actions";
47
+ import { AppPinButton } from "./app-pin-button";
48
48
  import { Badge } from "./ui/badge";
49
49
  import { Button } from "./ui/button";
50
50
  import {
@@ -248,13 +248,6 @@ export function WorkspaceAppCard({
248
248
  ...(whenToUseChanged ? { whenToUse: draftWhenToUseValues } : {}),
249
249
  });
250
250
  };
251
- const pinLabel = t(
252
- isPinned ? "dispatch.pages.unpinApp" : "dispatch.pages.pinApp",
253
- {
254
- name: app.name,
255
- defaultValue: isPinned ? "Unpin this app" : "Pin this app",
256
- },
257
- );
258
251
 
259
252
  return (
260
253
  <>
@@ -297,16 +290,21 @@ export function WorkspaceAppCard({
297
290
  </div>
298
291
  </div>
299
292
  <div className="flex shrink-0 items-center gap-2">
293
+ {onTogglePinned ? (
294
+ <AppPinButton
295
+ appId={app.id}
296
+ name={app.name}
297
+ pinned={isPinned}
298
+ onToggle={onTogglePinned}
299
+ />
300
+ ) : null}
300
301
  <WorkspaceAppOpenActions
301
302
  app={app}
302
303
  href={directHref ?? href}
303
304
  openDirectly={Boolean(
304
305
  directHref && shouldOpenWorkspaceAppInTopWindow(),
305
306
  )}
306
- isPinned={isPinned}
307
- pinLabel={pinLabel}
308
307
  pendingOpenLabel={pendingOpenLabel}
309
- onTogglePinned={onTogglePinned}
310
308
  />
311
309
  <WorkspaceAppSettings
312
310
  app={app}
@@ -432,19 +430,14 @@ function WorkspaceAppOpenActions({
432
430
  app,
433
431
  href,
434
432
  openDirectly,
435
- isPinned,
436
- pinLabel,
437
433
  pendingOpenLabel,
438
- onTogglePinned,
439
434
  }: {
440
435
  app: WorkspaceAppSummary;
441
436
  href: string | null;
442
437
  openDirectly: boolean;
443
- isPinned: boolean;
444
- pinLabel: string;
445
438
  pendingOpenLabel: string;
446
- onTogglePinned?: () => void;
447
439
  }) {
440
+ const t = useT();
448
441
  const navigate = useNavigate();
449
442
  const appRoute = workspaceAppRoute(app.id);
450
443
  const isPending = app.status === "pending";
@@ -466,7 +459,7 @@ function WorkspaceAppOpenActions({
466
459
  if (!href) {
467
460
  return (
468
461
  <Button size="sm" variant="outline" disabled>
469
- Open app
462
+ {t("dispatch.pages.openApp", { defaultValue: "Open" })}
470
463
  </Button>
471
464
  );
472
465
  }
@@ -476,22 +469,11 @@ function WorkspaceAppOpenActions({
476
469
  name={app.name}
477
470
  href={href}
478
471
  showNewTabOption
472
+ labels={appOpenActionLabels(t, app.name)}
479
473
  onOpen={() => {
480
474
  if (openDirectly && navigateToWorkspaceApp(href)) return;
481
475
  void navigate(appRoute);
482
476
  }}
483
- menuItems={
484
- onTogglePinned
485
- ? [
486
- {
487
- id: "pin",
488
- label: pinLabel,
489
- icon: <IconPin size={14} strokeWidth={isPinned ? 2.2 : 1.7} />,
490
- onSelect: onTogglePinned,
491
- },
492
- ]
493
- : undefined
494
- }
495
477
  />
496
478
  );
497
479
  }
@@ -0,0 +1,106 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const appState = vi.hoisted(() => ({
7
+ read: vi.fn(),
8
+ write: vi.fn(),
9
+ deviceOk: true,
10
+ }));
11
+
12
+ vi.mock("@agent-native/core/client/application-state", () => ({
13
+ readClientAppState: appState.read,
14
+ writeClientAppState: appState.write,
15
+ }));
16
+
17
+ vi.mock("@agent-native/core/client/agent-chat", async (importOriginal) => ({
18
+ ...(await importOriginal<
19
+ typeof import("@agent-native/core/client/agent-chat")
20
+ >()),
21
+ writeChatFirstAppLayout: () =>
22
+ appState.deviceOk ? { ok: true } : { ok: false, reason: "write-failed" },
23
+ }));
24
+
25
+ vi.mock("@agent-native/core/client/i18n", () => ({
26
+ useT: () => (key: string) => key,
27
+ }));
28
+
29
+ import {
30
+ resetWorkspaceAppLayoutStoreForTests,
31
+ updateWorkspaceAppLayout,
32
+ } from "../lib/workspace-app-layout";
33
+ import { WorkspaceAppLayoutNotice } from "./workspace-app-layout-notice";
34
+
35
+ const pin = (id: string) =>
36
+ updateWorkspaceAppLayout((layout) => ({
37
+ ...layout,
38
+ pinnedIds: [id, ...layout.pinnedIds],
39
+ }));
40
+
41
+ describe("WorkspaceAppLayoutNotice", () => {
42
+ let container: HTMLDivElement;
43
+ let root: Root;
44
+
45
+ beforeEach(async () => {
46
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
47
+ resetWorkspaceAppLayoutStoreForTests();
48
+ appState.deviceOk = true;
49
+ appState.read.mockReset().mockResolvedValue(null);
50
+ appState.write.mockReset().mockResolvedValue(null);
51
+ container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ root = createRoot(container);
54
+ await act(async () => root.render(<WorkspaceAppLayoutNotice />));
55
+ // Let the first change read workspace state, so later ones write it.
56
+ await act(async () => pin("seed"));
57
+ });
58
+
59
+ afterEach(() => {
60
+ act(() => root.unmount());
61
+ container.remove();
62
+ vi.unstubAllGlobals();
63
+ });
64
+
65
+ const notice = () =>
66
+ container.querySelector('[role="status"] [data-app-notice-message]');
67
+ const dismiss = () =>
68
+ container.querySelector<HTMLButtonElement>('[role="status"] button')!;
69
+
70
+ it.each([
71
+ [false, true, "dispatch.pages.appPinSaveFailed"],
72
+ [false, false, "dispatch.pages.appPinSaveFailed"],
73
+ [true, false, "dispatch.pages.appPinSavedToWorkspaceOnly"],
74
+ ])(
75
+ "says where pins were saved (workspace %s, device %s)",
76
+ async (workspaceOk, deviceOk, message) => {
77
+ appState.deviceOk = deviceOk;
78
+ appState.write.mockImplementation(() =>
79
+ workspaceOk ? Promise.resolve(null) : Promise.reject(new Error("x")),
80
+ );
81
+ await act(async () => pin("mail"));
82
+
83
+ expect(notice()?.textContent).toBe(message);
84
+ },
85
+ );
86
+
87
+ it("shows nothing once a later save succeeds", async () => {
88
+ appState.write.mockRejectedValueOnce(new Error("offline"));
89
+ await act(async () => pin("mail"));
90
+ expect(notice()).not.toBeNull();
91
+
92
+ await act(async () => pin("plan"));
93
+ expect(notice()).toBeNull();
94
+ });
95
+
96
+ it("shows again for a new failure after being dismissed", async () => {
97
+ appState.write.mockRejectedValue(new Error("offline"));
98
+ await act(async () => pin("mail"));
99
+ expect(dismiss().textContent).toBe("agentChat.chatFirst.dismiss");
100
+ await act(async () => dismiss().click());
101
+ expect(notice()).toBeNull();
102
+
103
+ await act(async () => pin("plan"));
104
+ expect(notice()?.textContent).toBe("dispatch.pages.appPinSaveFailed");
105
+ });
106
+ });
@@ -0,0 +1,65 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import { useState, type ReactNode } from "react";
3
+
4
+ import {
5
+ useWorkspaceAppLayout,
6
+ type WorkspaceAppLayoutPersistenceError,
7
+ } from "../lib/workspace-app-layout";
8
+ import { Alert, AlertDescription } from "./ui/alert";
9
+ import { Button } from "./ui/button";
10
+
11
+ const NOTICE_KEYS: Record<WorkspaceAppLayoutPersistenceError, string> = {
12
+ both: "dispatch.pages.appPinSaveFailed",
13
+ workspace: "dispatch.pages.appPinSaveFailed",
14
+ device: "dispatch.pages.appPinSavedToWorkspaceOnly",
15
+ };
16
+
17
+ /** A dismissible notice above the page content. */
18
+ export function AppNotice({
19
+ children,
20
+ onDismiss,
21
+ }: {
22
+ children: ReactNode;
23
+ onDismiss: () => void;
24
+ }) {
25
+ const t = useT();
26
+ return (
27
+ <Alert
28
+ role="status"
29
+ aria-live="polite"
30
+ data-app-notice
31
+ className="flex items-center justify-between gap-3 rounded-none border-x-0 border-t-0 px-3 py-2"
32
+ >
33
+ <AlertDescription
34
+ data-app-notice-message
35
+ className="min-w-0 text-xs text-muted-foreground"
36
+ >
37
+ {children}
38
+ </AlertDescription>
39
+ <Button
40
+ type="button"
41
+ variant="ghost"
42
+ size="sm"
43
+ className="shrink-0"
44
+ onClick={onDismiss}
45
+ >
46
+ {t("agentChat.chatFirst.dismiss")}
47
+ </Button>
48
+ </Alert>
49
+ );
50
+ }
51
+
52
+ /** The one place a failed app pin save is shown, on every route. */
53
+ export function WorkspaceAppLayoutNotice() {
54
+ const t = useT();
55
+ const { persistenceError } = useWorkspaceAppLayout({ hydrate: false });
56
+ const [dismissedWriteId, setDismissedWriteId] = useState<number | null>(null);
57
+ if (!persistenceError || persistenceError.writeId === dismissedWriteId) {
58
+ return null;
59
+ }
60
+ return (
61
+ <AppNotice onDismiss={() => setDismissedWriteId(persistenceError.writeId)}>
62
+ {t(NOTICE_KEYS[persistenceError.scope])}
63
+ </AppNotice>
64
+ );
65
+ }
@@ -0,0 +1,53 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ vi.mock("@agent-native/core/client/i18n", () => ({
7
+ useT: () => (key: string) => key,
8
+ }));
9
+
10
+ import { WorkspaceAppSearch } from "./workspace-app-search";
11
+
12
+ describe("WorkspaceAppSearch", () => {
13
+ let container: HTMLDivElement;
14
+ let root: Root;
15
+
16
+ beforeEach(() => {
17
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
18
+ container = document.createElement("div");
19
+ document.body.appendChild(container);
20
+ root = createRoot(container);
21
+ });
22
+
23
+ afterEach(() => {
24
+ act(() => root.unmount());
25
+ container.remove();
26
+ vi.unstubAllGlobals();
27
+ });
28
+
29
+ it("clears from a button inside the field, not beside it", async () => {
30
+ const onQueryChange = vi.fn();
31
+ await act(async () =>
32
+ root.render(
33
+ <WorkspaceAppSearch query="" onQueryChange={onQueryChange} />,
34
+ ),
35
+ );
36
+ const group = container.querySelector('[data-slot="input-group"]');
37
+ expect(group?.querySelector("input")).not.toBeNull();
38
+ expect(container.querySelector("button")).toBeNull();
39
+
40
+ await act(async () =>
41
+ root.render(
42
+ <WorkspaceAppSearch query="mail" onQueryChange={onQueryChange} />,
43
+ ),
44
+ );
45
+ const clear = container.querySelector<HTMLButtonElement>(
46
+ 'button[aria-label="dispatch.pages.clearAppSearch"]',
47
+ );
48
+ expect(clear?.closest('[data-slot="input-group"]')).toBe(group);
49
+ await act(async () => clear?.click());
50
+ expect(onQueryChange).toHaveBeenCalledWith("");
51
+ expect(document.activeElement).toBe(group?.querySelector("input"));
52
+ });
53
+ });
@@ -1,10 +1,15 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
+ import {
3
+ InputGroup,
4
+ InputGroupAddon,
5
+ InputGroupButton,
6
+ InputGroupInput,
7
+ } from "@agent-native/toolkit/ui/input-group";
2
8
  import { IconSearch, IconX } from "@tabler/icons-react";
3
- import { useId } from "react";
9
+ import { useId, useRef } from "react";
4
10
 
5
11
  import { cn } from "../lib/utils";
6
12
  import { Button } from "./ui/button";
7
- import { Input } from "./ui/input";
8
13
 
9
14
  export function WorkspaceAppSearch({
10
15
  query,
@@ -17,6 +22,7 @@ export function WorkspaceAppSearch({
17
22
  }) {
18
23
  const t = useT();
19
24
  const inputId = useId();
25
+ const inputRef = useRef<HTMLInputElement>(null);
20
26
  const label = t("dispatch.pages.searchApps", {
21
27
  defaultValue: "Search apps",
22
28
  });
@@ -24,43 +30,44 @@ export function WorkspaceAppSearch({
24
30
  defaultValue: "Search apps",
25
31
  });
26
32
 
33
+ const clearLabel = t("dispatch.pages.clearAppSearch", {
34
+ defaultValue: "Clear search",
35
+ });
36
+
27
37
  return (
28
- <div role="search" className={cn("flex items-center gap-2", className)}>
29
- <div className="relative min-w-0 flex-1">
30
- <label htmlFor={inputId} className="sr-only">
31
- {label}
32
- </label>
33
- <IconSearch
34
- size={16}
35
- aria-hidden="true"
36
- className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground"
37
- />
38
- <Input
38
+ <div role="search" className={cn("flex items-center", className)}>
39
+ <label htmlFor={inputId} className="sr-only">
40
+ {label}
41
+ </label>
42
+ <InputGroup size="sm" className="min-w-0 flex-1">
43
+ <InputGroupAddon>
44
+ <IconSearch aria-hidden="true" />
45
+ </InputGroupAddon>
46
+ <InputGroupInput
47
+ ref={inputRef}
39
48
  id={inputId}
40
- size="sm"
41
49
  value={query}
42
50
  onChange={(event) => onQueryChange(event.target.value)}
43
51
  placeholder={placeholder}
44
- className="bg-background ps-9"
45
52
  autoComplete="off"
46
53
  />
47
- </div>
48
- {query ? (
49
- <Button
50
- type="button"
51
- variant="ghost"
52
- size="sm"
53
- className="shrink-0 gap-1.5 text-muted-foreground"
54
- onClick={() => onQueryChange("")}
55
- >
56
- <IconX size={14} aria-hidden="true" />
57
- <span>
58
- {t("dispatch.pages.clearAppSearch", {
59
- defaultValue: "Clear",
60
- })}
61
- </span>
62
- </Button>
63
- ) : null}
54
+ {query ? (
55
+ <InputGroupAddon align="inline-end">
56
+ <InputGroupButton
57
+ size="icon-xs"
58
+ aria-label={clearLabel}
59
+ title={clearLabel}
60
+ onClick={() => {
61
+ onQueryChange("");
62
+ // The button unmounts once the query is empty.
63
+ inputRef.current?.focus();
64
+ }}
65
+ >
66
+ <IconX aria-hidden="true" />
67
+ </InputGroupButton>
68
+ </InputGroupAddon>
69
+ ) : null}
70
+ </InputGroup>
64
71
  </div>
65
72
  );
66
73
  }
@@ -32,6 +32,11 @@ vi.mock("@agent-native/core/client/hooks", () => ({
32
32
  }));
33
33
 
34
34
  vi.mock("sonner", () => ({ toast }));
35
+ vi.mock("@agent-native/core/client/i18n", async (importOriginal) => ({
36
+ ...(await importOriginal<typeof import("@agent-native/core/client/i18n")>()),
37
+ useT: () => (key: string, values?: { name?: string }) =>
38
+ values?.name ? `${key}:${values.name}` : key,
39
+ }));
35
40
 
36
41
  const template: CuratedWorkspaceTemplate = {
37
42
  id: "weekly-report",
@@ -194,6 +199,11 @@ describe("WorkspaceTemplateCard", () => {
194
199
  expect(
195
200
  container.querySelector('a[href="https://reports.example.test"]'),
196
201
  ).not.toBeNull();
202
+ expect(
203
+ container
204
+ .querySelector(".app-open-actions__menu")
205
+ ?.getAttribute("aria-label"),
206
+ ).toBe("extensions.optionsFor:Weekly report");
197
207
  });
198
208
 
199
209
  it("accepts the list action envelope and renders installed state", async () => {
@@ -1,4 +1,5 @@
1
1
  import { useActionMutation } from "@agent-native/core/client/hooks";
2
+ import { useT } from "@agent-native/core/client/i18n";
2
3
  import { normalizeWorkspaceAppId } from "@agent-native/core/shared";
3
4
  import {
4
5
  IconArrowUpRight,
@@ -12,7 +13,7 @@ import { toast } from "sonner";
12
13
  import { cn } from "../lib/utils";
13
14
  import { AppIcon } from "./app-icon";
14
15
  import { AppListRow } from "./app-list-row";
15
- import { AppOpenActions } from "./app-open-actions";
16
+ import { AppOpenActions, appOpenActionLabels } from "./app-open-actions";
16
17
  import { AppRowSettings } from "./app-row-settings";
17
18
  import { useNotifyBuildInBuilder } from "./build-in-builder";
18
19
  import { Button } from "./ui/button";
@@ -131,6 +132,7 @@ export function WorkspaceTemplateCard({
131
132
  catalog = false,
132
133
  onRemixSuccess,
133
134
  }: WorkspaceTemplateCardProps) {
135
+ const t = useT();
134
136
  const labels = useMemo(() => mergeLabels(labelOverrides), [labelOverrides]);
135
137
  const [open, setOpen] = useState(false);
136
138
  const [appId, setAppId] = useState(() =>
@@ -218,7 +220,10 @@ export function WorkspaceTemplateCard({
218
220
  name={template.name}
219
221
  href={liveUrl}
220
222
  rel="noreferrer"
221
- labels={{ openApp: labels.openApp }}
223
+ labels={{
224
+ ...appOpenActionLabels(t, template.name),
225
+ openApp: labels.openApp,
226
+ }}
222
227
  showNewTabOption
223
228
  />
224
229
  ) : null}
@@ -62,6 +62,15 @@ describe("Dispatch workspace app share access", () => {
62
62
  ).resolves.toBe(false);
63
63
  });
64
64
 
65
+ it("shares one manager answer across a request org's apps", () => {
66
+ const key = mocks.getRegisteredResource().canManageAccessKey as (resource: {
67
+ orgId?: string | null;
68
+ }) => string | undefined;
69
+
70
+ expect(key({ orgId: "org-a" })).toBe("org-a");
71
+ expect(key({ orgId: null })).toBeUndefined();
72
+ });
73
+
65
74
  it("does not resolve a role for a resource outside the request org", async () => {
66
75
  await expect(
67
76
  canManageAccess()(
package/src/db/index.ts CHANGED
@@ -21,6 +21,7 @@ registerShareableResource({
21
21
  allowPublic: false,
22
22
  requireOrgMemberForUserShares: true,
23
23
  supportsGroupShares: true,
24
+ canManageAccessKey: (resource) => resource.orgId ?? undefined,
24
25
  canManageAccess: async (resource, ctx) => {
25
26
  if (!ctx.userEmail || !ctx.orgId || resource?.orgId !== ctx.orgId) {
26
27
  return false;
@@ -50,6 +50,31 @@ describe("filterBuiltInApps", () => {
50
50
  ]);
51
51
  });
52
52
 
53
+ it("leaves out hosted apps the organization hides unless asked to keep them", () => {
54
+ const apps = [
55
+ {
56
+ id: "slides",
57
+ name: "Slides",
58
+ url: "https://slides.agent-native.com",
59
+ source: "builtin" as const,
60
+ hiddenFromLaunchers: true,
61
+ },
62
+ {
63
+ id: "clips",
64
+ name: "Clips",
65
+ url: "https://clips.agent-native.com",
66
+ source: "builtin" as const,
67
+ },
68
+ ];
69
+
70
+ expect(filterBuiltInApps(apps, []).map((app) => app.id)).toEqual(["clips"]);
71
+ expect(
72
+ filterBuiltInApps(apps, [], { includeHiddenFromLaunchers: true }).map(
73
+ (app) => app.id,
74
+ ),
75
+ ).toEqual(["clips", "slides"]);
76
+ });
77
+
53
78
  it("keeps built-in app launches on the current beta lane", () => {
54
79
  vi.stubGlobal("window", {
55
80
  location: { hostname: "beta.agent-workspace.builder.io" },
@@ -11,6 +11,8 @@ export interface ConnectedAppSummary {
11
11
  homeUrl?: string;
12
12
  color?: string;
13
13
  source?: "builtin" | "custom" | "workspace";
14
+ /** Set by list-connected-agents when the organization hides hosted apps. */
15
+ hiddenFromLaunchers?: boolean;
14
16
  }
15
17
 
16
18
  export interface WorkspaceAppId {
@@ -58,9 +60,15 @@ export function filterOtherApps(
58
60
  .sort((a, b) => a.name.localeCompare(b.name));
59
61
  }
60
62
 
63
+ /**
64
+ * Hosted apps for launcher surfaces. Rows the organization hides are left out
65
+ * unless `includeHiddenFromLaunchers` is set, which callers that resolve an
66
+ * app id to open (pane restore, `open_app`) need.
67
+ */
61
68
  export function filterBuiltInApps(
62
69
  connectedApps: ConnectedAppSummary[],
63
70
  workspaceApps: WorkspaceAppId[],
71
+ { includeHiddenFromLaunchers = false } = {},
64
72
  ): ConnectedAppSummary[] {
65
73
  const workspaceAppIds = new Set([
66
74
  "dispatch",
@@ -75,6 +83,7 @@ export function filterBuiltInApps(
75
83
  if (
76
84
  !id ||
77
85
  app.source !== "builtin" ||
86
+ (app.hiddenFromLaunchers && !includeHiddenFromLaunchers) ||
78
87
  isDefaultWorkspaceAppHiddenId(id) ||
79
88
  workspaceAppIds.has(id) ||
80
89
  seen.has(id) ||
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  normalizeWorkspaceAppLayout,
5
+ setWorkspaceAppPinned,
5
6
  orderWorkspaceApps,
6
- toggleWorkspaceAppPinned,
7
+ tierWorkspaceApps,
7
8
  workspaceAppMatchesQuery,
9
+ workspaceAppsStatus,
10
+ appListsStatus,
8
11
  } from "./workspace-app-layout";
9
12
  import { mergeChatFirstWorkspaceApps } from "./workspace-apps";
10
13
 
@@ -51,15 +54,70 @@ describe("workspace app layout", () => {
51
54
  expect(workspaceAppMatchesQuery({ name: "Mail" }, "calendar")).toBe(false);
52
55
  });
53
56
 
54
- it("normalizes persisted layout ids and toggles pin state", () => {
57
+ it("normalizes persisted layout ids and sets pin state", () => {
55
58
  const layout = normalizeWorkspaceAppLayout({
56
59
  pinnedIds: ["Mail", "mail", 42],
57
60
  orderedIds: ["Calendar", "", "calendar"],
58
61
  });
59
62
 
60
- expect(toggleWorkspaceAppPinned(layout, "CALENDAR")).toEqual({
63
+ expect(setWorkspaceAppPinned(layout, "CALENDAR", true)).toEqual({
61
64
  pinnedIds: ["calendar", "mail"],
62
65
  orderedIds: ["calendar"],
63
66
  });
67
+ // Already in the asked-for state: nothing moves.
68
+ expect(
69
+ setWorkspaceAppPinned(
70
+ { pinnedIds: ["a", "b"], orderedIds: [] },
71
+ "b",
72
+ true,
73
+ ),
74
+ ).toEqual({ pinnedIds: ["a", "b"], orderedIds: [] });
75
+ expect(setWorkspaceAppPinned(layout, "mail", false).pinnedIds).toEqual([]);
76
+ expect(setWorkspaceAppPinned(layout, "plan", false).pinnedIds).toEqual([
77
+ "mail",
78
+ ]);
79
+ });
80
+
81
+ it("tiers pinned apps in pin order, then apps and hosted apps each A to Z", () => {
82
+ const apps = [
83
+ { id: "gamma", name: "Gamma" },
84
+ { id: "zeta", name: "Zeta" },
85
+ { id: "mail", name: "Mail", source: "builtin" },
86
+ { id: "Alpha", name: "alpha" },
87
+ { id: "calendar", name: "Calendar", source: "builtin" },
88
+ { id: "clips", name: "Clips", source: "builtin" },
89
+ { id: "beta", name: "Beta", source: "connected" },
90
+ ];
91
+
92
+ const tiers = tierWorkspaceApps(apps, {
93
+ pinnedIds: ["clips", "zeta", "missing"],
94
+ orderedIds: ["beta", "alpha"],
95
+ });
96
+
97
+ expect(tiers.pinned.map((app) => app.id)).toEqual(["clips", "zeta"]);
98
+ expect(tiers.apps.map((app) => app.id)).toEqual(["Alpha", "beta", "gamma"]);
99
+ expect(tiers.hosted.map((app) => app.id)).toEqual(["calendar", "mail"]);
100
+ });
101
+
102
+ it.each([
103
+ [{ data: undefined, isError: false }, "loading"],
104
+ [{ data: undefined, isError: true }, "error"],
105
+ [{ data: [], isError: false }, "ready"],
106
+ // A failed refresh keeps the list it already had.
107
+ [{ data: [], isError: true }, "ready"],
108
+ ] as const)("reports %o as %s", (query, status) => {
109
+ expect(workspaceAppsStatus(query)).toBe(status);
110
+ });
111
+
112
+ it("keeps the app lists loading until the hosted list settles", () => {
113
+ const ready = { data: [], isError: false };
114
+ const pending = { data: undefined, isError: false };
115
+ const failed = { data: undefined, isError: true };
116
+ expect(appListsStatus(pending, ready)).toBe("loading");
117
+ expect(appListsStatus(failed, ready)).toBe("error");
118
+ expect(appListsStatus(ready, pending)).toBe("loading");
119
+ // A failed hosted list is reported on its own; workspace apps still show.
120
+ expect(appListsStatus(ready, failed)).toBe("ready");
121
+ expect(appListsStatus(ready, ready)).toBe("ready");
64
122
  });
65
123
  });