@agent-native/dispatch 0.19.2 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/actions/provider-api-register.d.ts +5 -5
  2. package/dist/actions/start-workspace-app-creation.js +1 -1
  3. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  4. package/dist/components/admin-navigation.d.ts +2 -1
  5. package/dist/components/admin-navigation.d.ts.map +1 -1
  6. package/dist/components/admin-navigation.js +10 -7
  7. package/dist/components/admin-navigation.js.map +1 -1
  8. package/dist/components/app-icon.d.ts +9 -0
  9. package/dist/components/app-icon.d.ts.map +1 -0
  10. package/dist/components/app-icon.js +85 -0
  11. package/dist/components/app-icon.js.map +1 -0
  12. package/dist/components/app-list-row.d.ts +12 -0
  13. package/dist/components/app-list-row.d.ts.map +1 -0
  14. package/dist/components/app-list-row.js +11 -0
  15. package/dist/components/app-list-row.js.map +1 -0
  16. package/dist/components/app-open-actions.d.ts +17 -0
  17. package/dist/components/app-open-actions.d.ts.map +1 -0
  18. package/dist/components/app-open-actions.js +20 -0
  19. package/dist/components/app-open-actions.js.map +1 -0
  20. package/dist/components/connected-app-card.d.ts +2 -1
  21. package/dist/components/connected-app-card.d.ts.map +1 -1
  22. package/dist/components/connected-app-card.js +8 -8
  23. package/dist/components/connected-app-card.js.map +1 -1
  24. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  25. package/dist/components/dispatch-control-plane.js +21 -14
  26. package/dist/components/dispatch-control-plane.js.map +1 -1
  27. package/dist/components/layout/Layout.d.ts.map +1 -1
  28. package/dist/components/layout/Layout.js +40 -19
  29. package/dist/components/layout/Layout.js.map +1 -1
  30. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  31. package/dist/components/layout/workspace-apps-rail.js +10 -10
  32. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  33. package/dist/components/other-apps-section.d.ts +31 -0
  34. package/dist/components/other-apps-section.d.ts.map +1 -0
  35. package/dist/components/other-apps-section.js +59 -0
  36. package/dist/components/other-apps-section.js.map +1 -0
  37. package/dist/components/workspace-app-card.d.ts.map +1 -1
  38. package/dist/components/workspace-app-card.js +38 -28
  39. package/dist/components/workspace-app-card.js.map +1 -1
  40. package/dist/components/workspace-template-card.d.ts +8 -2
  41. package/dist/components/workspace-template-card.d.ts.map +1 -1
  42. package/dist/components/workspace-template-card.js +17 -13
  43. package/dist/components/workspace-template-card.js.map +1 -1
  44. package/dist/lib/other-apps.d.ts.map +1 -1
  45. package/dist/lib/other-apps.js +5 -1
  46. package/dist/lib/other-apps.js.map +1 -1
  47. package/dist/lib/workspace-apps.d.ts +1 -0
  48. package/dist/lib/workspace-apps.d.ts.map +1 -1
  49. package/dist/lib/workspace-apps.js +3 -0
  50. package/dist/lib/workspace-apps.js.map +1 -1
  51. package/dist/routes/index.d.ts.map +1 -1
  52. package/dist/routes/index.js +0 -2
  53. package/dist/routes/index.js.map +1 -1
  54. package/dist/routes/pages/admin._index.d.ts.map +1 -1
  55. package/dist/routes/pages/admin._index.js +32 -7
  56. package/dist/routes/pages/admin._index.js.map +1 -1
  57. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  58. package/dist/routes/pages/apps.$appId.js +62 -13
  59. package/dist/routes/pages/apps.$appId.js.map +1 -1
  60. package/dist/routes/pages/apps.d.ts +1 -1
  61. package/dist/routes/pages/apps.d.ts.map +1 -1
  62. package/dist/routes/pages/apps.js +21 -30
  63. package/dist/routes/pages/apps.js.map +1 -1
  64. package/dist/server/plugins/agent-chat.js +6 -1
  65. package/dist/server/plugins/agent-chat.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/actions/start-workspace-app-creation.ts +1 -1
  68. package/src/components/admin-navigation.tsx +21 -16
  69. package/src/components/app-icon.tsx +149 -0
  70. package/src/components/app-list-row.tsx +40 -0
  71. package/src/components/app-open-actions.tsx +111 -0
  72. package/src/components/connected-app-card.tsx +30 -34
  73. package/src/components/dispatch-control-plane.spec.tsx +28 -1
  74. package/src/components/dispatch-control-plane.tsx +122 -48
  75. package/src/components/layout/Layout.spec.tsx +4 -4
  76. package/src/components/layout/Layout.tsx +86 -55
  77. package/src/components/layout/workspace-apps-rail.tsx +20 -21
  78. package/src/components/other-apps-section.tsx +195 -0
  79. package/src/components/workspace-app-card.spec.tsx +58 -32
  80. package/src/components/workspace-app-card.tsx +222 -158
  81. package/src/components/workspace-template-card.spec.tsx +45 -7
  82. package/src/components/workspace-template-card.tsx +74 -81
  83. package/src/lib/other-apps.spec.ts +31 -0
  84. package/src/lib/other-apps.ts +9 -1
  85. package/src/lib/workspace-apps.ts +4 -0
  86. package/src/routes/index.spec.ts +2 -1
  87. package/src/routes/index.ts +0 -2
  88. package/src/routes/pages/admin._index.tsx +85 -17
  89. package/src/routes/pages/apps.$appId.tsx +144 -57
  90. package/src/routes/pages/apps.tsx +76 -147
  91. package/src/server/plugins/agent-chat.spec.ts +5 -0
  92. package/src/server/plugins/agent-chat.ts +6 -1
@@ -173,7 +173,7 @@ describe("Dispatch NavContent", () => {
173
173
  expect(lists[1].className).toContain("gap-1");
174
174
  expect(lists[1].querySelector('a[href="/admin"]')).not.toBeNull();
175
175
  expect(lists[1].querySelector('a[href="/settings"]')).not.toBeNull();
176
- expect(lists[0].querySelector("a")?.className).toContain("h-8 w-8");
176
+ expect(lists[0].querySelector("a")?.className).toContain("size-9");
177
177
  });
178
178
 
179
179
  it("keeps management routes out of the primary navigation", async () => {
@@ -218,7 +218,7 @@ describe("Dispatch NavContent", () => {
218
218
  expect(shell?.querySelector('a[href="/admin/apps"]')).toBeNull();
219
219
  });
220
220
 
221
- it("shows ready workspace apps as direct links and highlights the active app", async () => {
221
+ it("shows ready workspace apps as host links and highlights the active app", async () => {
222
222
  clientState.workspaceApps = [
223
223
  { id: "calendar", name: "Calendar", path: "/calendar", status: "ready" },
224
224
  { id: "pending", name: "Pending", path: "/pending", status: "pending" },
@@ -226,7 +226,7 @@ describe("Dispatch NavContent", () => {
226
226
 
227
227
  await act(async () => {
228
228
  root.render(
229
- <MemoryRouter initialEntries={["/calendar/events"]}>
229
+ <MemoryRouter initialEntries={["/apps/calendar"]}>
230
230
  <TooltipProvider>
231
231
  <NavContent />
232
232
  </TooltipProvider>
@@ -239,7 +239,7 @@ describe("Dispatch NavContent", () => {
239
239
  expect(rail?.textContent).toContain("Calendar");
240
240
  expect(rail?.textContent).not.toContain("Pending");
241
241
 
242
- const calendarLink = rail?.querySelector('a[href="/calendar"]');
242
+ const calendarLink = rail?.querySelector('a[href="/apps/calendar"]');
243
243
  expect(calendarLink).not.toBeNull();
244
244
  expect(calendarLink?.getAttribute("aria-current")).toBe("page");
245
245
  });
@@ -41,7 +41,7 @@ import {
41
41
  type ComponentType,
42
42
  type ReactNode,
43
43
  } from "react";
44
- import { NavLink, useLocation, useNavigate } from "react-router";
44
+ import { Link, useLocation, useNavigate } from "react-router";
45
45
 
46
46
  import { cn } from "../../lib/utils";
47
47
  import {
@@ -147,6 +147,7 @@ function pageOwnsToolbar(pathname: string): boolean {
147
147
  if (pathname === "/tools" || pathname.startsWith("/tools/")) return true;
148
148
  if (pathname === "/extensions" || pathname.startsWith("/extensions/"))
149
149
  return true;
150
+ if (pathname.startsWith("/apps/")) return true;
150
151
  return false;
151
152
  }
152
153
 
@@ -556,7 +557,7 @@ export function NavContent({
556
557
  {collapsed ? (
557
558
  <Tooltip>
558
559
  <TooltipTrigger asChild>
559
- <NavLink
560
+ <Link
560
561
  to={dispatchNavLinkTarget(item.to)}
561
562
  onClick={(event) => {
562
563
  if (
@@ -578,27 +579,24 @@ export function NavContent({
578
579
  onNavigate?.();
579
580
  }}
580
581
  aria-label={label}
581
- className={({ isActive }) => {
582
- const active = isActive || itemMatchesLocalPath;
583
- return cn(
584
- "flex h-8 w-8 items-center justify-center rounded-md text-sm",
585
- active
586
- ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
587
- : "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
588
- );
589
- }}
582
+ className={cn(
583
+ "flex size-9 items-center justify-center rounded-md text-sm",
584
+ itemMatchesLocalPath
585
+ ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
586
+ : "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
587
+ )}
590
588
  >
591
589
  {Icon ? (
592
590
  <Icon size={16} className="shrink-0" />
593
591
  ) : (
594
592
  <span className="h-4 w-4 shrink-0" aria-hidden="true" />
595
593
  )}
596
- </NavLink>
594
+ </Link>
597
595
  </TooltipTrigger>
598
596
  <TooltipContent side="right">{label}</TooltipContent>
599
597
  </Tooltip>
600
598
  ) : (
601
- <NavLink
599
+ <Link
602
600
  to={dispatchNavLinkTarget(item.to)}
603
601
  onClick={(event) => {
604
602
  if (
@@ -619,15 +617,12 @@ export function NavContent({
619
617
  }
620
618
  onNavigate?.();
621
619
  }}
622
- className={({ isActive }) => {
623
- const active = isActive || itemMatchesLocalPath;
624
- return cn(
625
- "flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm",
626
- active
627
- ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
628
- : "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
629
- );
630
- }}
620
+ className={cn(
621
+ "flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm",
622
+ itemMatchesLocalPath
623
+ ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
624
+ : "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
625
+ )}
631
626
  >
632
627
  {Icon ? (
633
628
  <Icon size={16} className="shrink-0" />
@@ -635,7 +630,7 @@ export function NavContent({
635
630
  <span className="h-4 w-4 shrink-0" aria-hidden="true" />
636
631
  )}
637
632
  <span className="truncate">{label}</span>
638
- </NavLink>
633
+ </Link>
639
634
  )}
640
635
  {!collapsed && item.id === "chat" && itemMatchesLocalPath ? (
641
636
  <DispatchChatsSection onNavigate={onNavigate} />
@@ -646,46 +641,55 @@ export function NavContent({
646
641
 
647
642
  return (
648
643
  <>
649
- <div className={cn("border-b py-3", collapsed ? "px-1" : "px-4")}>
644
+ <div
645
+ className={cn(
646
+ "flex h-12 shrink-0 items-center border-b border-sidebar-border",
647
+ collapsed ? "justify-center px-0" : "px-4",
648
+ )}
649
+ >
650
650
  <div
651
651
  className={cn(
652
652
  "flex items-center",
653
653
  collapsed ? "justify-center" : "gap-2",
654
654
  )}
655
655
  >
656
+ <img
657
+ src={appPath("/agent-native-icon-light.svg")}
658
+ alt=""
659
+ aria-hidden="true"
660
+ width={35}
661
+ height={20}
662
+ className={cn(
663
+ "block shrink-0 object-contain object-center dark:hidden",
664
+ collapsed ? "h-4 w-7" : "h-5 w-[35px]",
665
+ )}
666
+ />
667
+ <img
668
+ src={appPath("/agent-native-icon-dark.svg")}
669
+ alt=""
670
+ aria-hidden="true"
671
+ width={35}
672
+ height={20}
673
+ className={cn(
674
+ "hidden shrink-0 object-contain object-center dark:block",
675
+ collapsed ? "h-4 w-7" : "h-5 w-[35px]",
676
+ )}
677
+ />
656
678
  {!collapsed && (
657
- <>
658
- <img
659
- src={appPath("/agent-native-icon-light.svg")}
660
- alt=""
661
- aria-hidden="true"
662
- width={35}
663
- height={20}
664
- className="block h-5 w-[35px] shrink-0 object-contain object-center dark:hidden"
665
- />
666
- <img
667
- src={appPath("/agent-native-icon-dark.svg")}
668
- alt=""
669
- aria-hidden="true"
670
- width={35}
671
- height={20}
672
- className="hidden h-5 w-[35px] shrink-0 object-contain object-center dark:block"
673
- />
674
- <div className="min-w-0 flex-1">
675
- <div
676
- data-dispatch-sidebar-label
677
- className="truncate text-lg font-bold tracking-tight text-foreground"
678
- >
679
- {DISPATCH_SIDEBAR_LABEL}
680
- </div>
679
+ <div className="min-w-0 flex-1">
680
+ <div
681
+ data-dispatch-sidebar-label
682
+ className="truncate text-lg font-bold tracking-tight text-foreground"
683
+ >
684
+ {DISPATCH_SIDEBAR_LABEL}
681
685
  </div>
682
- </>
686
+ </div>
683
687
  )}
684
688
  </div>
685
689
  </div>
686
690
 
687
691
  <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
688
- <nav className={cn("py-3", collapsed ? "px-1" : "px-2")}>
692
+ <nav className={cn("py-2", collapsed ? "px-1.5" : "px-2")}>
689
693
  <ul
690
694
  className={cn(
691
695
  collapsed ? "flex flex-col items-center gap-1" : "space-y-0.5",
@@ -698,7 +702,7 @@ export function NavContent({
698
702
  </nav>
699
703
 
700
704
  <div className="mt-auto shrink-0">
701
- <nav className={cn("py-1", collapsed ? "px-1" : "px-2")}>
705
+ <nav className={cn("py-1", collapsed ? "px-1.5" : "px-2")}>
702
706
  <ul
703
707
  className={cn(
704
708
  collapsed ? "flex flex-col items-center gap-1" : "space-y-0.5",
@@ -751,6 +755,9 @@ export function Layout({
751
755
  const localPathname = localDispatchPath(location.pathname);
752
756
  const isChatRoute =
753
757
  localPathname === "/chat" || localPathname.startsWith("/chat/");
758
+ const isWorkspaceAppHostRoute = localPathname.startsWith("/apps/");
759
+ const sidebarBeforeAppRef = useRef<boolean | null>(null);
760
+ const sidebarAutoCollapsedRef = useRef(false);
754
761
  const chatHomeHandoffActive = useAgentChatHomeHandoff({
755
762
  storageKey: "dispatch",
756
763
  activePath: localPathname,
@@ -765,7 +772,29 @@ export function Layout({
765
772
  useAgentChatHomeHandoffLinks(chatHandoffLinkOptions);
766
773
 
767
774
  useEffect(() => {
768
- if (typeof window === "undefined") return;
775
+ if (isWorkspaceAppHostRoute) {
776
+ if (!sidebarAutoCollapsedRef.current) {
777
+ sidebarAutoCollapsedRef.current = true;
778
+ sidebarBeforeAppRef.current = sidebarCollapsed;
779
+ }
780
+ if (!sidebarCollapsed) setSidebarCollapsed(true);
781
+ return;
782
+ }
783
+
784
+ if (!sidebarAutoCollapsedRef.current) return;
785
+ sidebarAutoCollapsedRef.current = false;
786
+ const previousSidebarState = sidebarBeforeAppRef.current;
787
+ sidebarBeforeAppRef.current = null;
788
+ if (
789
+ previousSidebarState !== null &&
790
+ previousSidebarState !== sidebarCollapsed
791
+ ) {
792
+ setSidebarCollapsed(previousSidebarState);
793
+ }
794
+ }, [isWorkspaceAppHostRoute, sidebarCollapsed]);
795
+
796
+ useEffect(() => {
797
+ if (typeof window === "undefined" || isWorkspaceAppHostRoute) return;
769
798
  try {
770
799
  window.localStorage.setItem(
771
800
  SIDEBAR_COLLAPSE_KEY,
@@ -774,7 +803,7 @@ export function Layout({
774
803
  } catch {
775
804
  // Ignore storage failures; the in-memory preference still works.
776
805
  }
777
- }, [sidebarCollapsed]);
806
+ }, [isWorkspaceAppHostRoute, sidebarCollapsed]);
778
807
 
779
808
  if (CHROMELESS_PATHS.some((path) => localPathname === path)) {
780
809
  return <>{children}</>;
@@ -800,7 +829,9 @@ export function Layout({
800
829
  <main
801
830
  className={cn(
802
831
  "flex-1",
803
- isChatRoute ? "min-h-0 overflow-hidden" : "overflow-y-auto",
832
+ isChatRoute || isWorkspaceAppHostRoute
833
+ ? "min-h-0 overflow-hidden"
834
+ : "overflow-y-auto",
804
835
  )}
805
836
  >
806
837
  {showHeader ? (
@@ -840,7 +871,7 @@ export function Layout({
840
871
  <aside
841
872
  data-collapsed={sidebarCollapsed ? "true" : "false"}
842
873
  className={cn(
843
- "agent-layout-left-drawer hidden shrink-0 flex-col border-e bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-out lg:flex",
874
+ "agent-layout-left-drawer hidden shrink-0 flex-col border-e !border-e-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-out lg:flex",
844
875
  sidebarCollapsed ? "w-14" : "w-56",
845
876
  )}
846
877
  >
@@ -1,13 +1,15 @@
1
1
  import { useActionQuery } from "@agent-native/core/client/hooks";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
3
  import { IconApps } from "@tabler/icons-react";
4
- import { useLocation } from "react-router";
4
+ import { Link, useLocation } from "react-router";
5
5
 
6
6
  import { cn } from "../../lib/utils";
7
7
  import {
8
+ workspaceAppRoute,
8
9
  workspaceAppHref,
9
10
  type WorkspaceAppSummary,
10
11
  } from "../../lib/workspace-apps";
12
+ import { AppIcon } from "../app-icon";
11
13
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
12
14
 
13
15
  function pathFromValue(value: string | null | undefined): string | null {
@@ -24,6 +26,11 @@ function pathFromValue(value: string | null | undefined): string | null {
24
26
  }
25
27
 
26
28
  function appMatchesPath(app: WorkspaceAppSummary, pathname: string): boolean {
29
+ const appRoute = workspaceAppRoute(app.id);
30
+ if (pathname === appRoute || pathname.startsWith(`${appRoute}/`)) {
31
+ return true;
32
+ }
33
+
27
34
  const candidatePaths = [app.path, app.url]
28
35
  .map(pathFromValue)
29
36
  .filter((path): path is string => !!path);
@@ -33,10 +40,6 @@ function appMatchesPath(app: WorkspaceAppSummary, pathname: string): boolean {
33
40
  );
34
41
  }
35
42
 
36
- function appInitial(name: string): string {
37
- return name.trim().charAt(0).toUpperCase() || "A";
38
- }
39
-
40
43
  function appLabel(app: WorkspaceAppSummary): string {
41
44
  return app.name.trim() || app.id;
42
45
  }
@@ -77,31 +80,27 @@ export function WorkspaceAppsRail({
77
80
  const label = appLabel(app);
78
81
  const active = appMatchesPath(app, location.pathname);
79
82
  const link = (
80
- <a
81
- href={href}
83
+ <Link
84
+ to={workspaceAppRoute(app.id)}
82
85
  aria-current={active ? "page" : undefined}
83
86
  aria-label={collapsed ? label : undefined}
84
87
  onClick={onNavigate}
85
88
  className={cn(
86
- "flex h-8 items-center rounded-md text-sm transition-colors",
87
- collapsed ? "w-8 justify-center" : "w-full gap-2 px-2 text-start",
89
+ "flex h-9 items-center rounded-md text-sm transition-colors",
90
+ collapsed ? "w-9 justify-center" : "w-full gap-2 px-2 text-start",
88
91
  active
89
92
  ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
90
93
  : "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
91
94
  )}
92
95
  >
93
- <span
94
- className={cn(
95
- "flex size-5 shrink-0 items-center justify-center rounded text-[10px] font-semibold",
96
- active
97
- ? "bg-sidebar-primary text-sidebar-primary-foreground"
98
- : "bg-sidebar-accent text-sidebar-foreground/70",
99
- )}
100
- >
101
- {appInitial(label)}
102
- </span>
96
+ <AppIcon
97
+ id={app.id}
98
+ name={label}
99
+ size="sm"
100
+ className={cn("size-5 rounded-md", active && "ring-1 ring-ring/30")}
101
+ />
103
102
  {!collapsed ? <span className="truncate">{label}</span> : null}
104
- </a>
103
+ </Link>
105
104
  );
106
105
 
107
106
  if (!collapsed) return link;
@@ -118,7 +117,7 @@ export function WorkspaceAppsRail({
118
117
  <div
119
118
  className={cn(
120
119
  "mt-4 border-t border-sidebar-border pt-3",
121
- collapsed ? "px-1" : "px-2",
120
+ collapsed ? "px-1.5" : "px-2",
122
121
  )}
123
122
  data-dispatch-apps-rail
124
123
  >
@@ -0,0 +1,195 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import { IconStack2 } from "@tabler/icons-react";
3
+
4
+ import { filterOtherApps, type ConnectedAppSummary } from "../lib/other-apps";
5
+ import type { WorkspaceAppId } from "../lib/other-apps";
6
+ import { cn } from "../lib/utils";
7
+ import { ActionQueryError } from "./action-query-error";
8
+ import {
9
+ APP_LIST_GRID_CLASS,
10
+ APP_LIST_GRID_ROW_CLASS,
11
+ AppList,
12
+ } from "./app-list-row";
13
+ import { ConnectedAppCard } from "./connected-app-card";
14
+ import { Skeleton } from "./ui/skeleton";
15
+ import {
16
+ WorkspaceTemplateCard,
17
+ type CuratedWorkspaceTemplate,
18
+ type CuratedWorkspaceTemplatesResult,
19
+ type WorkspaceTemplateLabels,
20
+ } from "./workspace-template-card";
21
+
22
+ type OtherAppEntry =
23
+ | { kind: "template"; template: CuratedWorkspaceTemplate }
24
+ | { kind: "connected"; app: ConnectedAppSummary };
25
+
26
+ function templateKey(template: CuratedWorkspaceTemplate): string {
27
+ return (template.templateId || template.id || template.appId || template.name)
28
+ .trim()
29
+ .toLowerCase();
30
+ }
31
+
32
+ function getTemplateItems(
33
+ result: CuratedWorkspaceTemplatesResult | undefined,
34
+ ): CuratedWorkspaceTemplate[] {
35
+ if (!result) return [];
36
+ return Array.isArray(result) ? result : result.templates;
37
+ }
38
+
39
+ export function mergeOtherAppEntries({
40
+ templates,
41
+ connectedApps,
42
+ workspaceApps,
43
+ }: {
44
+ templates?: CuratedWorkspaceTemplatesResult;
45
+ connectedApps: ConnectedAppSummary[];
46
+ workspaceApps: WorkspaceAppId[];
47
+ }): OtherAppEntry[] {
48
+ const workspaceAppIds = new Set(
49
+ workspaceApps.map((app) => app.id.trim().toLowerCase()),
50
+ );
51
+ const seen = new Set<string>();
52
+ const entries: OtherAppEntry[] = [];
53
+
54
+ for (const template of getTemplateItems(templates)) {
55
+ const id = templateKey(template);
56
+ if (!id || template.installed || workspaceAppIds.has(id)) continue;
57
+ seen.add(id);
58
+ entries.push({ kind: "template", template });
59
+ }
60
+
61
+ for (const app of filterOtherApps(connectedApps, workspaceApps)) {
62
+ const id = app.id.trim().toLowerCase();
63
+ if (seen.has(id)) continue;
64
+ seen.add(id);
65
+ entries.push({ kind: "connected", app });
66
+ }
67
+
68
+ return entries;
69
+ }
70
+
71
+ export function OtherAppsSection({
72
+ templates,
73
+ connectedApps = [],
74
+ workspaceApps,
75
+ templatesLoading = false,
76
+ connectedAppsLoading = false,
77
+ templatesError,
78
+ connectedAppsError,
79
+ onRetryTemplates,
80
+ onRetryConnectedApps,
81
+ templateLabels,
82
+ onRemixSuccess,
83
+ className,
84
+ }: {
85
+ templates?: CuratedWorkspaceTemplatesResult;
86
+ connectedApps?: ConnectedAppSummary[];
87
+ workspaceApps: WorkspaceAppId[];
88
+ templatesLoading?: boolean;
89
+ connectedAppsLoading?: boolean;
90
+ templatesError?: Error | null;
91
+ connectedAppsError?: Error | null;
92
+ onRetryTemplates?: () => void;
93
+ onRetryConnectedApps?: () => void;
94
+ templateLabels?: Partial<WorkspaceTemplateLabels>;
95
+ onRemixSuccess?: (
96
+ result: unknown,
97
+ template: CuratedWorkspaceTemplate,
98
+ ) => void;
99
+ className?: string;
100
+ }) {
101
+ const t = useT();
102
+ const entries = mergeOtherAppEntries({
103
+ templates,
104
+ connectedApps,
105
+ workspaceApps,
106
+ });
107
+ const isLoading = templatesLoading || connectedAppsLoading;
108
+ const hasError = Boolean(templatesError || connectedAppsError);
109
+
110
+ if (!isLoading && !hasError && entries.length === 0) return null;
111
+
112
+ return (
113
+ <section className={cn("space-y-3 border-t pt-6", className)}>
114
+ <div className="flex min-w-0 items-start gap-2">
115
+ <IconStack2
116
+ size={16}
117
+ className="mt-0.5 shrink-0 text-muted-foreground"
118
+ />
119
+ <div className="min-w-0">
120
+ <h2 className="truncate text-sm font-semibold text-foreground">
121
+ {t("dispatch.pages.otherApps", { defaultValue: "Other apps" })}
122
+ </h2>
123
+ {entries.length > 0 ? (
124
+ <p className="mt-0.5 text-xs text-muted-foreground">
125
+ {t("dispatch.pages.availableCount", {
126
+ count: entries.length,
127
+ })}
128
+ </p>
129
+ ) : null}
130
+ </div>
131
+ </div>
132
+
133
+ {templatesError ? (
134
+ <ActionQueryError
135
+ error={templatesError}
136
+ onRetry={() => onRetryTemplates?.()}
137
+ />
138
+ ) : null}
139
+ {connectedAppsError ? (
140
+ <ActionQueryError
141
+ error={connectedAppsError}
142
+ onRetry={() => onRetryConnectedApps?.()}
143
+ />
144
+ ) : null}
145
+
146
+ {isLoading && entries.length === 0 ? (
147
+ <OtherAppsSkeletonList />
148
+ ) : entries.length > 0 ? (
149
+ <AppList className={APP_LIST_GRID_CLASS}>
150
+ {entries.map((entry) =>
151
+ entry.kind === "template" ? (
152
+ <WorkspaceTemplateCard
153
+ key={`template:${templateKey(entry.template)}`}
154
+ template={entry.template}
155
+ labels={templateLabels}
156
+ catalog
157
+ className={APP_LIST_GRID_ROW_CLASS}
158
+ onRemixSuccess={onRemixSuccess}
159
+ />
160
+ ) : (
161
+ <ConnectedAppCard
162
+ key={`connected:${entry.app.id}`}
163
+ app={entry.app}
164
+ className={APP_LIST_GRID_ROW_CLASS}
165
+ />
166
+ ),
167
+ )}
168
+ </AppList>
169
+ ) : null}
170
+ </section>
171
+ );
172
+ }
173
+
174
+ function OtherAppsSkeletonList() {
175
+ return (
176
+ <AppList className={APP_LIST_GRID_CLASS}>
177
+ {Array.from({ length: 4 }).map((_, index) => (
178
+ <div
179
+ key={index}
180
+ className={cn(
181
+ "flex min-w-0 items-center gap-3 border-b px-4 py-3.5 last:border-b-0",
182
+ APP_LIST_GRID_ROW_CLASS,
183
+ )}
184
+ >
185
+ <Skeleton className="size-8 shrink-0 rounded-xl" />
186
+ <div className="min-w-0 flex-1 space-y-2">
187
+ <Skeleton className="h-4 w-32" />
188
+ <Skeleton className="h-3 w-2/3" />
189
+ </div>
190
+ <Skeleton className="h-9 w-24 shrink-0 rounded-md" />
191
+ </div>
192
+ ))}
193
+ </AppList>
194
+ );
195
+ }