@agent-native/dispatch 0.19.1 → 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 +9 -9
  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 +3 -3
  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
@@ -1,31 +1,43 @@
1
- import { useActionQuery } from "@agent-native/core/client/hooks";
1
+ import {
2
+ useActionMutation,
3
+ useActionQuery,
4
+ } from "@agent-native/core/client/hooks";
2
5
  import { useT } from "@agent-native/core/client/i18n";
3
6
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
4
- import {
5
- IconArrowLeft,
6
- IconArrowUpRight,
7
- IconClockHour4,
8
- } from "@tabler/icons-react";
9
- import { useEffect, useMemo } from "react";
10
- import { Link, useLocation, useParams } from "react-router";
7
+ import { IconArrowLeft, IconClockHour4 } from "@tabler/icons-react";
8
+ import { useEffect, useMemo, useState } from "react";
9
+ import { Link, useParams } from "react-router";
11
10
 
12
11
  import { ActionQueryError } from "../../components/action-query-error";
13
- import { DispatchShell } from "../../components/dispatch-shell";
14
12
  import { Badge } from "../../components/ui/badge";
15
13
  import { Button } from "../../components/ui/button";
16
14
  import { Skeleton } from "../../components/ui/skeleton";
15
+ import { Spinner } from "../../components/ui/spinner";
17
16
  import {
18
17
  workspaceAppHref,
19
18
  type WorkspaceAppSummary,
20
19
  } from "../../lib/workspace-apps";
21
20
 
21
+ interface EmbedSessionResult {
22
+ startUrl: string;
23
+ targetPath?: string;
24
+ expiresAt?: number;
25
+ app: string;
26
+ }
27
+
28
+ interface EmbedSessionInput {
29
+ app: string;
30
+ path?: string;
31
+ url?: string;
32
+ chrome: "minimal";
33
+ }
34
+
22
35
  export function meta() {
23
36
  return [{ title: "Workspace app - Dispatch" }];
24
37
  }
25
38
 
26
39
  export default function WorkspaceAppRoute() {
27
40
  const t = useT();
28
- const location = useLocation();
29
41
  const { appId } = useParams();
30
42
  const appsQuery = useActionQuery("list-workspace-apps", {
31
43
  includeAgentCards: false,
@@ -37,41 +49,88 @@ export default function WorkspaceAppRoute() {
37
49
  [appId, apps],
38
50
  );
39
51
  const href = app ? workspaceAppHref(app) : null;
52
+ const [embedUrl, setEmbedUrl] = useState<string | null>(null);
53
+ const [embedError, setEmbedError] = useState<Error | null>(null);
54
+ const [embedAttempt, setEmbedAttempt] = useState(0);
55
+ const createEmbedSession = useActionMutation<
56
+ EmbedSessionResult,
57
+ EmbedSessionInput
58
+ >("create_embed_session", {
59
+ skipActionQueryInvalidation: true,
60
+ });
61
+ const embedInput = useMemo<EmbedSessionInput | null>(() => {
62
+ if (!app || !href) return null;
63
+ const path = app.path.trim();
64
+ if (path.startsWith("/")) {
65
+ return { app: app.id, path, chrome: "minimal" };
66
+ }
67
+ return { app: app.id, url: href, chrome: "minimal" };
68
+ }, [app?.id, app?.path, href]);
40
69
 
41
70
  useEffect(() => {
42
- if (!app || app.status === "pending" || !href) return;
43
- window.location.assign(href);
44
- }, [app, href]);
71
+ if (!app || app.status === "pending" || !embedInput) return;
72
+ let cancelled = false;
73
+ setEmbedUrl(null);
74
+ setEmbedError(null);
75
+ void createEmbedSession
76
+ .mutateAsync(embedInput)
77
+ .then((result) => {
78
+ if (!cancelled) setEmbedUrl(result.startUrl);
79
+ })
80
+ .catch((cause: unknown) => {
81
+ if (!cancelled) {
82
+ setEmbedError(
83
+ cause instanceof Error ? cause : new Error(String(cause)),
84
+ );
85
+ }
86
+ });
87
+ return () => {
88
+ cancelled = true;
89
+ };
90
+ }, [
91
+ app?.id,
92
+ app?.path,
93
+ app?.status,
94
+ createEmbedSession.mutateAsync,
95
+ embedAttempt,
96
+ embedInput,
97
+ ]);
45
98
 
46
- return (
47
- <DispatchShell
48
- title={app?.name || t("dispatch.pages.workspaceAppFallback")}
49
- description={t("dispatch.pages.workspaceAppDescription")}
50
- >
51
- <div className="max-w-2xl rounded-lg bg-card p-5">
52
- <Button asChild size="sm" variant="ghost" className="-ml-2 mb-4">
53
- <Link
54
- to={
55
- location.pathname.startsWith("/admin/") ? "/admin/apps" : "/apps"
56
- }
57
- >
58
- <IconArrowLeft size={15} className="mr-1.5" />
59
- {t("dispatch.nav.apps")}
60
- </Link>
61
- </Button>
62
-
63
- {appsQuery.isError ? (
99
+ if (appsQuery.isError) {
100
+ return (
101
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
102
+ <div className="w-full max-w-2xl">
64
103
  <ActionQueryError
65
104
  error={appsQuery.error}
66
105
  onRetry={() => void appsQuery.refetch()}
67
106
  />
68
- ) : isLoading && !app ? (
69
- <div className="space-y-3">
70
- <Skeleton className="h-5 w-48" />
71
- <Skeleton className="h-4 w-full" />
72
- <Skeleton className="h-4 w-2/3" />
73
- </div>
74
- ) : !app ? (
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ if (isLoading && !app) {
113
+ return (
114
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
115
+ <div className="w-full max-w-2xl space-y-3 rounded-xl border bg-card p-6">
116
+ <Skeleton className="h-5 w-48" />
117
+ <Skeleton className="h-4 w-full" />
118
+ <Skeleton className="h-4 w-2/3" />
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ if (!app) {
125
+ return (
126
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
127
+ <div className="w-full max-w-2xl rounded-xl border bg-card p-6">
128
+ <Button asChild size="sm" variant="ghost" className="-ml-2 mb-4">
129
+ <Link to="/apps">
130
+ <IconArrowLeft size={15} className="mr-1.5" />
131
+ {t("dispatch.nav.apps")}
132
+ </Link>
133
+ </Button>
75
134
  <div className="space-y-3">
76
135
  <h2 className="text-base font-semibold text-foreground">
77
136
  {t("dispatch.pages.appNotFound")}
@@ -80,7 +139,21 @@ export default function WorkspaceAppRoute() {
80
139
  {t("dispatch.pages.pageNotFoundDescription")}
81
140
  </p>
82
141
  </div>
83
- ) : app.status === "pending" ? (
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ if (app.status === "pending") {
148
+ return (
149
+ <div className="flex h-full min-h-0 items-center justify-center p-6">
150
+ <div className="w-full max-w-2xl rounded-xl border bg-card p-6">
151
+ <Button asChild size="sm" variant="ghost" className="-ml-2 mb-4">
152
+ <Link to="/apps">
153
+ <IconArrowLeft size={15} className="mr-1.5" />
154
+ {t("dispatch.nav.apps")}
155
+ </Link>
156
+ </Button>
84
157
  <div className="space-y-4">
85
158
  <div className="flex flex-wrap items-center gap-2">
86
159
  <h2 className="text-base font-semibold text-foreground">
@@ -115,31 +188,45 @@ export default function WorkspaceAppRoute() {
115
188
  rel="noreferrer"
116
189
  >
117
190
  {t("dispatch.pages.openBuilderBranch")}
118
- <IconArrowUpRight size={15} className="ml-1.5" />
119
191
  </a>
120
192
  </Button>
121
193
  ) : null}
122
194
  </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ return (
201
+ <div
202
+ data-dispatch-workspace-app-host
203
+ className="flex h-full min-h-0 flex-col bg-background"
204
+ >
205
+ <div className="min-h-0 flex-1 bg-muted/20">
206
+ {embedUrl ? (
207
+ <iframe
208
+ data-dispatch-workspace-app-frame
209
+ src={embedUrl}
210
+ title={app.name}
211
+ referrerPolicy="no-referrer"
212
+ className="h-full w-full border-0 bg-background"
213
+ />
214
+ ) : embedError ? (
215
+ <div className="flex h-full items-center justify-center p-6">
216
+ <div className="w-full max-w-2xl">
217
+ <ActionQueryError
218
+ error={embedError}
219
+ onRetry={() => setEmbedAttempt((attempt) => attempt + 1)}
220
+ />
221
+ </div>
222
+ </div>
123
223
  ) : (
124
- <div className="space-y-3">
125
- <h2 className="text-base font-semibold text-foreground">
126
- {t("dispatch.pages.openingApp", { name: app.name })}
127
- </h2>
128
- <p className="text-sm text-muted-foreground">
129
- {t("dispatch.pages.redirectingTo")}{" "}
130
- <span className="font-mono text-foreground">{app.path}</span>.
131
- </p>
132
- {href ? (
133
- <Button asChild>
134
- <a href={href}>
135
- {t("dispatch.pages.openApp")}
136
- <IconArrowUpRight size={15} className="ml-1.5" />
137
- </a>
138
- </Button>
139
- ) : null}
224
+ <div className="flex h-full items-center justify-center">
225
+ <Spinner className="size-5 text-muted-foreground" />
226
+ <span className="sr-only">{t("dispatch.pages.loading")}</span>
140
227
  </div>
141
228
  )}
142
229
  </div>
143
- </DispatchShell>
230
+ </div>
144
231
  );
145
232
  }
@@ -6,14 +6,19 @@ import {
6
6
  IconClockHour4,
7
7
  IconEyeOff,
8
8
  IconPlus,
9
- IconStack2,
10
9
  } from "@tabler/icons-react";
11
10
  import { useState } from "react";
11
+ import { Outlet, useParams } from "react-router";
12
12
 
13
13
  import { ActionQueryError } from "../../components/action-query-error";
14
- import { ConnectedAppCard } from "../../components/connected-app-card";
14
+ import {
15
+ APP_LIST_GRID_CLASS,
16
+ APP_LIST_GRID_ROW_CLASS,
17
+ AppList,
18
+ } from "../../components/app-list-row";
15
19
  import { CreateAppPopover } from "../../components/create-app-popover";
16
20
  import { DispatchShell } from "../../components/dispatch-shell";
21
+ import { OtherAppsSection } from "../../components/other-apps-section";
17
22
  import { Button } from "../../components/ui/button";
18
23
  import {
19
24
  Collapsible,
@@ -22,15 +27,11 @@ import {
22
27
  } from "../../components/ui/collapsible";
23
28
  import { Skeleton } from "../../components/ui/skeleton";
24
29
  import { WorkspaceAppCard } from "../../components/workspace-app-card";
25
- import {
26
- WorkspaceTemplatesSection,
27
- type CuratedWorkspaceTemplatesResult,
28
- type WorkspaceTemplateLabels,
30
+ import type {
31
+ CuratedWorkspaceTemplatesResult,
32
+ WorkspaceTemplateLabels,
29
33
  } from "../../components/workspace-template-card";
30
- import {
31
- filterOtherApps,
32
- type ConnectedAppSummary,
33
- } from "../../lib/other-apps";
34
+ import type { ConnectedAppSummary } from "../../lib/other-apps";
34
35
  import { cn } from "../../lib/utils";
35
36
  import type { WorkspaceAppSummary } from "../../lib/workspace-apps";
36
37
 
@@ -44,7 +45,12 @@ interface WorkspaceInfo {
44
45
  appCount: number;
45
46
  }
46
47
 
47
- export default function AppsRoute() {
48
+ export default function AppsRouteEntry() {
49
+ const { appId } = useParams();
50
+ return appId ? <Outlet /> : <AppsRoute />;
51
+ }
52
+
53
+ function AppsRoute() {
48
54
  const t = useT();
49
55
  const [showPending, setShowPending] = useState(false);
50
56
  const [showHidden, setShowHidden] = useState(false);
@@ -72,10 +78,6 @@ export default function AppsRoute() {
72
78
  const activeApps = visibleApps.filter((app) => app.status !== "pending");
73
79
  const pendingApps = visibleApps.filter((app) => app.status === "pending");
74
80
  const archivedApps = allApps.filter((app) => app.archived);
75
- const otherApps = filterOtherApps(
76
- (connectedAppsQuery.data || []) as ConnectedAppSummary[],
77
- allApps,
78
- );
79
81
  const showAppSkeletons = appsLoading && allApps.length === 0;
80
82
  const templateLabels: WorkspaceTemplateLabels = {
81
83
  appId: t("dispatch.pages.remixAppIdLabel"),
@@ -83,13 +85,14 @@ export default function AppsRoute() {
83
85
  cancel: t("dispatch.pages.cancel"),
84
86
  integrationSetup: t("dispatch.pages.integrationSetup"),
85
87
  installed: t("dispatch.pages.alreadyInWorkspace"),
86
- remix: t("dispatch.pages.remix"),
88
+ remix: t("dispatch.pages.addApp", { defaultValue: "Add app" }),
87
89
  remixing: t("dispatch.pages.remixing"),
88
90
  remixSuccess: t("dispatch.pages.remixSuccess"),
89
91
  remixError: t("dispatch.pages.remixError"),
90
92
  appIdRequired: t("dispatch.pages.appIdRequired"),
91
93
  source: t("dispatch.pages.source"),
92
- viewLiveApp: t("dispatch.pages.viewLiveApp"),
94
+ openApp: t("dispatch.pages.openApp", { defaultValue: "Open app" }),
95
+ viewLiveApp: t("dispatch.pages.openApp", { defaultValue: "Open" }),
93
96
  };
94
97
 
95
98
  return (
@@ -113,11 +116,7 @@ export default function AppsRoute() {
113
116
  />
114
117
  <div className="min-w-0">
115
118
  <h2 className="truncate text-sm font-semibold text-foreground">
116
- {workspaceLabel
117
- ? t("dispatch.pages.appsInWorkspace", {
118
- workspace: workspaceLabel,
119
- })
120
- : t("dispatch.pages.workspaceApps")}
119
+ {t("dispatch.pages.yourApps", { defaultValue: "Your apps" })}
121
120
  </h2>
122
121
  <p className="mt-0.5 text-xs text-muted-foreground">
123
122
  {t("dispatch.pages.activeCount", {
@@ -140,9 +139,9 @@ export default function AppsRoute() {
140
139
  <CreateAppPopover
141
140
  align="end"
142
141
  trigger={
143
- <Button size="sm">
142
+ <Button size="sm" variant="outline">
144
143
  <IconPlus size={15} className="mr-1.5" />
145
- {t("dispatch.pages.createApp")}
144
+ {t("dispatch.pages.addApp", { defaultValue: "Add app" })}
146
145
  </Button>
147
146
  }
148
147
  />
@@ -157,11 +156,15 @@ export default function AppsRoute() {
157
156
  ) : showAppSkeletons ? (
158
157
  <AppsSkeletonGrid />
159
158
  ) : activeApps.length > 0 ? (
160
- <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
159
+ <AppList className={APP_LIST_GRID_CLASS}>
161
160
  {activeApps.map((app) => (
162
- <WorkspaceAppCard key={app.id} app={app} className="h-full" />
161
+ <WorkspaceAppCard
162
+ key={app.id}
163
+ app={app}
164
+ className={APP_LIST_GRID_ROW_CLASS}
165
+ />
163
166
  ))}
164
- </div>
167
+ </AppList>
165
168
  ) : pendingApps.length > 0 ? (
166
169
  <EmptyActiveAppsState />
167
170
  ) : (
@@ -171,7 +174,7 @@ export default function AppsRoute() {
171
174
 
172
175
  {pendingApps.length > 0 ? (
173
176
  <Collapsible open={showPending} onOpenChange={setShowPending}>
174
- <section className="space-y-3 border-t pt-4">
177
+ <section className="space-y-3 border-t pt-6">
175
178
  <div className="flex flex-wrap items-center justify-between gap-3">
176
179
  <div className="flex min-w-0 items-center gap-2">
177
180
  <IconClockHour4
@@ -183,7 +186,7 @@ export default function AppsRoute() {
183
186
  {t("dispatch.pages.pendingApps")}
184
187
  </h2>
185
188
  <p className="text-xs text-muted-foreground">
186
- {t("dispatch.pages.pendingAppsDescription", {
189
+ {t("dispatch.pages.pendingCount", {
187
190
  count: pendingApps.length,
188
191
  })}
189
192
  </p>
@@ -210,78 +213,42 @@ export default function AppsRoute() {
210
213
  </CollapsibleTrigger>
211
214
  </div>
212
215
  <CollapsibleContent>
213
- <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
216
+ <AppList className={APP_LIST_GRID_CLASS}>
214
217
  {pendingApps.map((app) => (
215
218
  <WorkspaceAppCard
216
219
  key={app.id}
217
220
  app={app}
218
- className="h-full"
221
+ className={APP_LIST_GRID_ROW_CLASS}
219
222
  />
220
223
  ))}
221
- </div>
224
+ </AppList>
222
225
  </CollapsibleContent>
223
226
  </section>
224
227
  </Collapsible>
225
228
  ) : null}
226
229
 
227
- {curatedTemplatesQuery.isError ? (
228
- <section className="space-y-3 border-t pt-4">
229
- <ActionQueryError
230
- error={curatedTemplatesQuery.error}
231
- onRetry={() => void curatedTemplatesQuery.refetch()}
232
- />
233
- </section>
234
- ) : curatedTemplatesQuery.data ? (
235
- <section className="space-y-3 border-t pt-4">
236
- <div className="flex min-w-0 items-start gap-2">
237
- <IconStack2
238
- size={16}
239
- className="mt-0.5 shrink-0 text-muted-foreground"
240
- />
241
- <div className="min-w-0">
242
- <h2 className="truncate text-sm font-semibold text-foreground">
243
- {t("dispatch.pages.curatedTemplates")}
244
- </h2>
245
- <p className="mt-0.5 text-xs text-muted-foreground">
246
- {t("dispatch.pages.curatedTemplatesDescription")}
247
- </p>
248
- </div>
249
- </div>
250
- <WorkspaceTemplatesSection
251
- templates={
252
- curatedTemplatesQuery.data as CuratedWorkspaceTemplatesResult
253
- }
254
- labels={templateLabels}
255
- onRemixSuccess={() => {
256
- void appsQuery.refetch();
257
- void curatedTemplatesQuery.refetch();
258
- }}
259
- />
260
- </section>
261
- ) : null}
262
-
263
- {connectedAppsQuery.isError ? (
264
- <section className="space-y-3 border-t pt-4">
265
- <OtherAppsHeading count={0} />
266
- <ActionQueryError
267
- error={connectedAppsQuery.error}
268
- onRetry={() => void connectedAppsQuery.refetch()}
269
- />
270
- </section>
271
- ) : connectedAppsQuery.isLoading || otherApps.length > 0 ? (
272
- <section className="space-y-3 border-t pt-4">
273
- <OtherAppsHeading count={otherApps.length} />
274
- {connectedAppsQuery.isLoading ? (
275
- <OtherAppsSkeletonGrid />
276
- ) : (
277
- <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
278
- {otherApps.map((app) => (
279
- <ConnectedAppCard key={app.id} app={app} />
280
- ))}
281
- </div>
282
- )}
283
- </section>
284
- ) : null}
230
+ <OtherAppsSection
231
+ templates={
232
+ curatedTemplatesQuery.data as
233
+ | CuratedWorkspaceTemplatesResult
234
+ | undefined
235
+ }
236
+ connectedApps={
237
+ connectedAppsQuery.data as ConnectedAppSummary[] | undefined
238
+ }
239
+ workspaceApps={allApps}
240
+ templatesLoading={curatedTemplatesQuery.isLoading}
241
+ connectedAppsLoading={connectedAppsQuery.isLoading}
242
+ templatesError={curatedTemplatesQuery.error}
243
+ connectedAppsError={connectedAppsQuery.error}
244
+ onRetryTemplates={() => void curatedTemplatesQuery.refetch()}
245
+ onRetryConnectedApps={() => void connectedAppsQuery.refetch()}
246
+ templateLabels={templateLabels}
247
+ onRemixSuccess={() => {
248
+ void appsQuery.refetch();
249
+ void curatedTemplatesQuery.refetch();
250
+ }}
251
+ />
285
252
 
286
253
  {archivedApps.length > 0 ? (
287
254
  <Collapsible open={showHidden} onOpenChange={setShowHidden}>
@@ -324,15 +291,15 @@ export default function AppsRoute() {
324
291
  </CollapsibleTrigger>
325
292
  </div>
326
293
  <CollapsibleContent>
327
- <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
294
+ <AppList className={APP_LIST_GRID_CLASS}>
328
295
  {archivedApps.map((app) => (
329
296
  <WorkspaceAppCard
330
297
  key={app.id}
331
298
  app={app}
332
- className="h-full"
299
+ className={APP_LIST_GRID_ROW_CLASS}
333
300
  />
334
301
  ))}
335
- </div>
302
+ </AppList>
336
303
  </CollapsibleContent>
337
304
  </section>
338
305
  </Collapsible>
@@ -342,64 +309,26 @@ export default function AppsRoute() {
342
309
  );
343
310
  }
344
311
 
345
- function OtherAppsHeading({ count }: { count: number }) {
346
- const t = useT();
347
- return (
348
- <div className="flex min-w-0 items-start gap-2">
349
- <IconStack2 size={16} className="mt-0.5 shrink-0 text-muted-foreground" />
350
- <div className="min-w-0">
351
- <h2 className="truncate text-sm font-semibold text-foreground">
352
- {t("dispatch.pages.otherApps")}
353
- </h2>
354
- <p className="mt-0.5 text-xs text-muted-foreground">
355
- {t("dispatch.pages.otherAppsDescription")}
356
- {count > 0
357
- ? ` · ${t("dispatch.pages.availableCount", { count })}`
358
- : ""}
359
- </p>
360
- </div>
361
- </div>
362
- );
363
- }
364
-
365
- function OtherAppsSkeletonGrid() {
366
- return (
367
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
368
- {Array.from({ length: 3 }).map((_, index) => (
369
- <div key={index} className="rounded-lg bg-card p-4">
370
- <div className="flex items-start gap-3">
371
- <Skeleton className="size-8 rounded-md" />
372
- <div className="flex-1 space-y-3">
373
- <Skeleton className="h-4 w-32" />
374
- <Skeleton className="h-3 w-full" />
375
- <Skeleton className="h-3 w-2/3" />
376
- </div>
377
- </div>
378
- </div>
379
- ))}
380
- </div>
381
- );
382
- }
383
-
384
312
  function AppsSkeletonGrid() {
385
313
  return (
386
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
314
+ <AppList className={APP_LIST_GRID_CLASS}>
387
315
  {Array.from({ length: 3 }).map((_, index) => (
388
- <div key={index} className="rounded-lg bg-card p-4">
389
- <div className="flex items-start justify-between gap-3">
390
- <div className="min-w-0 flex-1 space-y-3">
391
- <Skeleton className="h-4 w-32" />
392
- <Skeleton className="h-3 w-24" />
393
- <div className="space-y-2 pt-1">
394
- <Skeleton className="h-3 w-full" />
395
- <Skeleton className="h-3 w-2/3" />
396
- </div>
397
- </div>
398
- <Skeleton className="h-7 w-7 rounded-md" />
316
+ <div
317
+ key={index}
318
+ className={cn(
319
+ "flex min-w-0 items-center gap-3 border-b px-4 py-3.5 last:border-b-0",
320
+ APP_LIST_GRID_ROW_CLASS,
321
+ )}
322
+ >
323
+ <Skeleton className="size-8 shrink-0 rounded-md" />
324
+ <div className="min-w-0 flex-1 space-y-2">
325
+ <Skeleton className="h-4 w-32" />
326
+ <Skeleton className="h-3 w-2/3" />
399
327
  </div>
328
+ <Skeleton className="h-8 w-24 shrink-0 rounded-md" />
400
329
  </div>
401
330
  ))}
402
- </div>
331
+ </AppList>
403
332
  );
404
333
  }
405
334
 
@@ -419,9 +348,9 @@ function EmptyAppsState() {
419
348
  <div className="mt-4">
420
349
  <CreateAppPopover
421
350
  trigger={
422
- <Button size="sm">
351
+ <Button size="sm" variant="outline">
423
352
  <IconPlus size={15} className="mr-1.5" />
424
- {t("dispatch.pages.createApp")}
353
+ {t("dispatch.pages.addApp", { defaultValue: "Add app" })}
425
354
  </Button>
426
355
  }
427
356
  />