@agent-native/dispatch 0.19.2 → 0.20.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 (85) hide show
  1. package/dist/actions/provider-api-register.d.ts +5 -5
  2. package/dist/components/admin-navigation.d.ts +2 -1
  3. package/dist/components/admin-navigation.d.ts.map +1 -1
  4. package/dist/components/admin-navigation.js +10 -7
  5. package/dist/components/admin-navigation.js.map +1 -1
  6. package/dist/components/app-icon.d.ts +9 -0
  7. package/dist/components/app-icon.d.ts.map +1 -0
  8. package/dist/components/app-icon.js +85 -0
  9. package/dist/components/app-icon.js.map +1 -0
  10. package/dist/components/app-list-row.d.ts +12 -0
  11. package/dist/components/app-list-row.d.ts.map +1 -0
  12. package/dist/components/app-list-row.js +11 -0
  13. package/dist/components/app-list-row.js.map +1 -0
  14. package/dist/components/app-open-actions.d.ts +17 -0
  15. package/dist/components/app-open-actions.d.ts.map +1 -0
  16. package/dist/components/app-open-actions.js +20 -0
  17. package/dist/components/app-open-actions.js.map +1 -0
  18. package/dist/components/connected-app-card.d.ts +2 -1
  19. package/dist/components/connected-app-card.d.ts.map +1 -1
  20. package/dist/components/connected-app-card.js +8 -8
  21. package/dist/components/connected-app-card.js.map +1 -1
  22. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  23. package/dist/components/dispatch-control-plane.js +21 -14
  24. package/dist/components/dispatch-control-plane.js.map +1 -1
  25. package/dist/components/layout/Layout.d.ts.map +1 -1
  26. package/dist/components/layout/Layout.js +40 -19
  27. package/dist/components/layout/Layout.js.map +1 -1
  28. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  29. package/dist/components/layout/workspace-apps-rail.js +10 -10
  30. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  31. package/dist/components/other-apps-section.d.ts +31 -0
  32. package/dist/components/other-apps-section.d.ts.map +1 -0
  33. package/dist/components/other-apps-section.js +59 -0
  34. package/dist/components/other-apps-section.js.map +1 -0
  35. package/dist/components/workspace-app-card.d.ts.map +1 -1
  36. package/dist/components/workspace-app-card.js +38 -28
  37. package/dist/components/workspace-app-card.js.map +1 -1
  38. package/dist/components/workspace-template-card.d.ts +8 -2
  39. package/dist/components/workspace-template-card.d.ts.map +1 -1
  40. package/dist/components/workspace-template-card.js +17 -13
  41. package/dist/components/workspace-template-card.js.map +1 -1
  42. package/dist/lib/other-apps.d.ts.map +1 -1
  43. package/dist/lib/other-apps.js +5 -1
  44. package/dist/lib/other-apps.js.map +1 -1
  45. package/dist/lib/workspace-apps.d.ts +1 -0
  46. package/dist/lib/workspace-apps.d.ts.map +1 -1
  47. package/dist/lib/workspace-apps.js +3 -0
  48. package/dist/lib/workspace-apps.js.map +1 -1
  49. package/dist/routes/index.d.ts.map +1 -1
  50. package/dist/routes/index.js +0 -2
  51. package/dist/routes/index.js.map +1 -1
  52. package/dist/routes/pages/admin._index.d.ts.map +1 -1
  53. package/dist/routes/pages/admin._index.js +32 -7
  54. package/dist/routes/pages/admin._index.js.map +1 -1
  55. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  56. package/dist/routes/pages/apps.$appId.js +62 -13
  57. package/dist/routes/pages/apps.$appId.js.map +1 -1
  58. package/dist/routes/pages/apps.d.ts +1 -1
  59. package/dist/routes/pages/apps.d.ts.map +1 -1
  60. package/dist/routes/pages/apps.js +21 -30
  61. package/dist/routes/pages/apps.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/components/admin-navigation.tsx +21 -16
  64. package/src/components/app-icon.tsx +149 -0
  65. package/src/components/app-list-row.tsx +40 -0
  66. package/src/components/app-open-actions.tsx +111 -0
  67. package/src/components/connected-app-card.tsx +30 -34
  68. package/src/components/dispatch-control-plane.spec.tsx +28 -1
  69. package/src/components/dispatch-control-plane.tsx +122 -48
  70. package/src/components/layout/Layout.spec.tsx +4 -4
  71. package/src/components/layout/Layout.tsx +86 -55
  72. package/src/components/layout/workspace-apps-rail.tsx +20 -21
  73. package/src/components/other-apps-section.tsx +195 -0
  74. package/src/components/workspace-app-card.spec.tsx +58 -32
  75. package/src/components/workspace-app-card.tsx +222 -158
  76. package/src/components/workspace-template-card.spec.tsx +45 -7
  77. package/src/components/workspace-template-card.tsx +74 -81
  78. package/src/lib/other-apps.spec.ts +31 -0
  79. package/src/lib/other-apps.ts +9 -1
  80. package/src/lib/workspace-apps.ts +4 -0
  81. package/src/routes/index.spec.ts +2 -1
  82. package/src/routes/index.ts +0 -2
  83. package/src/routes/pages/admin._index.tsx +85 -17
  84. package/src/routes/pages/apps.$appId.tsx +144 -57
  85. package/src/routes/pages/apps.tsx +76 -147
@@ -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
+ }
@@ -41,7 +41,7 @@ describe("WorkspaceAppCard", () => {
41
41
  vi.unstubAllGlobals();
42
42
  });
43
43
 
44
- it("uses one visible, consistent action treatment for context, keys, and more", async () => {
44
+ it("uses one explicit open action and one settings control", async () => {
45
45
  await act(async () => {
46
46
  root.render(
47
47
  <TooltipProvider>
@@ -59,34 +59,49 @@ describe("WorkspaceAppCard", () => {
59
59
  });
60
60
 
61
61
  const appLink = container.querySelector<HTMLAnchorElement>(
62
- 'a[aria-label="Open Analytics"]',
62
+ 'a[href="/analytics"]',
63
63
  );
64
- expect(appLink?.getAttribute("href")).toBe("/analytics");
64
+ expect(appLink).not.toBeNull();
65
+ expect(appLink?.textContent).toContain("Open app");
66
+ expect(appLink?.className).toContain("bg-accent");
65
67
  expect(appLink?.getAttribute("target")).toBeNull();
66
68
  expect(appLink?.getAttribute("rel")).toBeNull();
67
- expect(appLink?.className).toContain("focus-visible:ring-2");
69
+ expect(appLink?.querySelector("svg")).toBeNull();
70
+ expect(container.textContent).not.toContain("/analytics");
68
71
 
69
- const actions = [
70
- container.querySelector<HTMLButtonElement>(
71
- 'button[aria-label="View agent resources for Analytics"]',
72
- ),
73
- container.querySelector<HTMLButtonElement>(
74
- 'button[aria-label="Manage keys for Analytics"]',
75
- ),
76
- container.querySelector<HTMLButtonElement>(
72
+ const openOptionsButton = container.querySelector<HTMLButtonElement>(
73
+ 'button[aria-label="Open options for Analytics"]',
74
+ );
75
+ expect(openOptionsButton).not.toBeNull();
76
+ await act(async () => {
77
+ openOptionsButton?.dispatchEvent(
78
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
79
+ );
80
+ });
81
+ const openInlineLink = Array.from(
82
+ document.querySelectorAll<HTMLAnchorElement>('[role="menu"] a'),
83
+ ).find((anchor) => anchor.textContent?.includes("Open inline"));
84
+ const openNewTabLink = Array.from(
85
+ document.querySelectorAll<HTMLAnchorElement>('[role="menu"] a'),
86
+ ).find((anchor) => anchor.textContent?.includes("Open in new tab"));
87
+ expect(openInlineLink?.getAttribute("target")).toBeNull();
88
+ expect(openNewTabLink?.getAttribute("target")).toBe("_blank");
89
+
90
+ const settingsButton = container.querySelector<HTMLButtonElement>(
91
+ 'button[aria-label="Settings for Analytics"]',
92
+ );
93
+ expect(settingsButton).not.toBeNull();
94
+ expect(settingsButton?.className).toContain("size-7");
95
+ expect(
96
+ container.querySelector(
77
97
  'button[aria-label="More actions for Analytics"]',
78
98
  ),
79
- ];
80
-
81
- for (const action of actions) {
82
- expect(action).not.toBeNull();
83
- expect(action?.className).toContain("size-7");
84
- expect(action?.className).toContain("text-muted-foreground");
85
- expect(action?.className).toContain(
86
- "transition-[background-color,color]",
87
- );
88
- expect(action?.className).not.toContain("opacity-0");
89
- }
99
+ ).toBeNull();
100
+ expect(
101
+ container.querySelector(
102
+ 'button[aria-label="View agent resources for Analytics"]',
103
+ ),
104
+ ).toBeNull();
90
105
  });
91
106
 
92
107
  it("labels per-app context as agent resources while preserving workspace scope", async () => {
@@ -106,11 +121,21 @@ describe("WorkspaceAppCard", () => {
106
121
  );
107
122
  });
108
123
 
109
- const resourcesButton = container.querySelector<HTMLButtonElement>(
110
- 'button[aria-label="View agent resources for Analytics"]',
124
+ const settingsButton = container.querySelector<HTMLButtonElement>(
125
+ 'button[aria-label="Settings for Analytics"]',
111
126
  );
112
127
 
113
- await act(async () => resourcesButton?.click());
128
+ await act(async () => {
129
+ settingsButton?.dispatchEvent(
130
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
131
+ );
132
+ });
133
+ const resourcesItem = Array.from(
134
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
135
+ ).find((item) => item.textContent?.includes("Agent resources"));
136
+ expect(resourcesItem).not.toBeUndefined();
137
+
138
+ await act(async () => resourcesItem?.click());
114
139
 
115
140
  expect(document.body.textContent).toContain("Analytics agent resources");
116
141
  expect(document.body.textContent).toContain(
@@ -139,8 +164,9 @@ describe("WorkspaceAppCard", () => {
139
164
  });
140
165
 
141
166
  const appLink = container.querySelector<HTMLAnchorElement>(
142
- 'a[aria-label="Open New app"]',
167
+ 'a[href="https://builder.example.com/projects/new-app"]',
143
168
  );
169
+ expect(appLink).not.toBeNull();
144
170
  expect(appLink?.getAttribute("href")).toBe(
145
171
  "https://builder.example.com/projects/new-app",
146
172
  );
@@ -148,7 +174,7 @@ describe("WorkspaceAppCard", () => {
148
174
  expect(appLink?.getAttribute("rel")).toBe("noreferrer");
149
175
  });
150
176
 
151
- it("shows ownership metadata in the more menu", async () => {
177
+ it("shows ownership metadata in the settings menu", async () => {
152
178
  await act(async () => {
153
179
  root.render(
154
180
  <TooltipProvider>
@@ -168,13 +194,13 @@ describe("WorkspaceAppCard", () => {
168
194
  );
169
195
  });
170
196
 
171
- const moreButton = container.querySelector<HTMLButtonElement>(
172
- 'button[aria-label="More actions for Analytics"]',
197
+ const settingsButton = container.querySelector<HTMLButtonElement>(
198
+ 'button[aria-label="Settings for Analytics"]',
173
199
  );
174
- expect(moreButton).not.toBeNull();
200
+ expect(settingsButton).not.toBeNull();
175
201
 
176
202
  await act(async () => {
177
- moreButton?.dispatchEvent(
203
+ settingsButton?.dispatchEvent(
178
204
  new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
179
205
  );
180
206
  });