@agent-native/dispatch 0.24.0 → 0.24.2

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 (161) hide show
  1. package/dist/actions/provider-api-register.d.ts +12 -12
  2. package/dist/components/admin-navigation.d.ts.map +1 -1
  3. package/dist/components/admin-navigation.js +21 -5
  4. package/dist/components/admin-navigation.js.map +1 -1
  5. package/dist/components/app-list-row.d.ts +1 -1
  6. package/dist/components/app-list-row.d.ts.map +1 -1
  7. package/dist/components/app-list-row.js +1 -1
  8. package/dist/components/app-list-row.js.map +1 -1
  9. package/dist/components/app-open-actions.d.ts +1 -17
  10. package/dist/components/app-open-actions.d.ts.map +1 -1
  11. package/dist/components/app-open-actions.js +1 -20
  12. package/dist/components/app-open-actions.js.map +1 -1
  13. package/dist/components/app-row-settings.d.ts +6 -0
  14. package/dist/components/app-row-settings.d.ts.map +1 -0
  15. package/dist/components/app-row-settings.js +11 -0
  16. package/dist/components/app-row-settings.js.map +1 -0
  17. package/dist/components/automation-run-history-dialog.d.ts.map +1 -1
  18. package/dist/components/automation-run-history-dialog.js +3 -2
  19. package/dist/components/automation-run-history-dialog.js.map +1 -1
  20. package/dist/components/connected-app-card.d.ts.map +1 -1
  21. package/dist/components/connected-app-card.js +1 -1
  22. package/dist/components/connected-app-card.js.map +1 -1
  23. package/dist/components/create-app-popover.js +2 -2
  24. package/dist/components/create-app-popover.js.map +1 -1
  25. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  26. package/dist/components/dispatch-control-plane.js +23 -7
  27. package/dist/components/dispatch-control-plane.js.map +1 -1
  28. package/dist/components/dispatch-shell.d.ts.map +1 -1
  29. package/dist/components/dispatch-shell.js +5 -1
  30. package/dist/components/dispatch-shell.js.map +1 -1
  31. package/dist/components/docs-link.d.ts +16 -0
  32. package/dist/components/docs-link.d.ts.map +1 -0
  33. package/dist/components/docs-link.js +34 -0
  34. package/dist/components/docs-link.js.map +1 -0
  35. package/dist/components/layout/Layout.d.ts +1 -0
  36. package/dist/components/layout/Layout.d.ts.map +1 -1
  37. package/dist/components/layout/Layout.js +37 -28
  38. package/dist/components/layout/Layout.js.map +1 -1
  39. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  40. package/dist/components/messaging-setup-panel.js +3 -2
  41. package/dist/components/messaging-setup-panel.js.map +1 -1
  42. package/dist/components/usage-alerts-panel.d.ts +11 -0
  43. package/dist/components/usage-alerts-panel.d.ts.map +1 -0
  44. package/dist/components/usage-alerts-panel.js +125 -0
  45. package/dist/components/usage-alerts-panel.js.map +1 -0
  46. package/dist/components/workspace-app-card.d.ts +3 -1
  47. package/dist/components/workspace-app-card.d.ts.map +1 -1
  48. package/dist/components/workspace-app-card.js +70 -29
  49. package/dist/components/workspace-app-card.js.map +1 -1
  50. package/dist/components/workspace-app-search.d.ts +10 -0
  51. package/dist/components/workspace-app-search.d.ts.map +1 -0
  52. package/dist/components/workspace-app-search.js +27 -0
  53. package/dist/components/workspace-app-search.js.map +1 -0
  54. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  55. package/dist/components/workspace-resource-effective-stack.js +3 -2
  56. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  57. package/dist/components/workspace-template-card.d.ts.map +1 -1
  58. package/dist/components/workspace-template-card.js +2 -1
  59. package/dist/components/workspace-template-card.js.map +1 -1
  60. package/dist/lib/workspace-app-layout.d.ts +19 -0
  61. package/dist/lib/workspace-app-layout.d.ts.map +1 -0
  62. package/dist/lib/workspace-app-layout.js +75 -0
  63. package/dist/lib/workspace-app-layout.js.map +1 -0
  64. package/dist/lib/workspace-apps.d.ts +10 -0
  65. package/dist/lib/workspace-apps.d.ts.map +1 -1
  66. package/dist/lib/workspace-apps.js +59 -0
  67. package/dist/lib/workspace-apps.js.map +1 -1
  68. package/dist/lib/workspace-apps.test.js +18 -1
  69. package/dist/lib/workspace-apps.test.js.map +1 -1
  70. package/dist/routes/pages/$appId.d.ts.map +1 -1
  71. package/dist/routes/pages/$appId.js +4 -2
  72. package/dist/routes/pages/$appId.js.map +1 -1
  73. package/dist/routes/pages/agents.d.ts.map +1 -1
  74. package/dist/routes/pages/agents.js +2 -3
  75. package/dist/routes/pages/agents.js.map +1 -1
  76. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  77. package/dist/routes/pages/apps.$appId.js +8 -3
  78. package/dist/routes/pages/apps.$appId.js.map +1 -1
  79. package/dist/routes/pages/apps.d.ts.map +1 -1
  80. package/dist/routes/pages/apps.js +31 -16
  81. package/dist/routes/pages/apps.js.map +1 -1
  82. package/dist/routes/pages/audit.d.ts.map +1 -1
  83. package/dist/routes/pages/audit.js +2 -1
  84. package/dist/routes/pages/audit.js.map +1 -1
  85. package/dist/routes/pages/dreams.d.ts.map +1 -1
  86. package/dist/routes/pages/dreams.js +14 -18
  87. package/dist/routes/pages/dreams.js.map +1 -1
  88. package/dist/routes/pages/integrations.d.ts.map +1 -1
  89. package/dist/routes/pages/integrations.js +51 -14
  90. package/dist/routes/pages/integrations.js.map +1 -1
  91. package/dist/routes/pages/metrics.d.ts.map +1 -1
  92. package/dist/routes/pages/metrics.js +4 -1
  93. package/dist/routes/pages/metrics.js.map +1 -1
  94. package/dist/routes/pages/operations.js +1 -1
  95. package/dist/routes/pages/operations.js.map +1 -1
  96. package/dist/routes/pages/settings.d.ts.map +1 -1
  97. package/dist/routes/pages/settings.js +7 -2
  98. package/dist/routes/pages/settings.js.map +1 -1
  99. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  100. package/dist/routes/pages/thread-debug.js +5 -8
  101. package/dist/routes/pages/thread-debug.js.map +1 -1
  102. package/dist/routes/pages/vault.d.ts.map +1 -1
  103. package/dist/routes/pages/vault.js +1 -1
  104. package/dist/routes/pages/vault.js.map +1 -1
  105. package/dist/routes/pages/workspace.d.ts.map +1 -1
  106. package/dist/routes/pages/workspace.js +13 -10
  107. package/dist/routes/pages/workspace.js.map +1 -1
  108. package/dist/server/index.d.ts.map +1 -1
  109. package/dist/server/index.js +3 -0
  110. package/dist/server/index.js.map +1 -1
  111. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  112. package/dist/server/lib/app-creation-store.js +12 -52
  113. package/dist/server/lib/app-creation-store.js.map +1 -1
  114. package/dist/shared/app-roles.d.ts +9 -0
  115. package/dist/shared/app-roles.d.ts.map +1 -0
  116. package/dist/shared/app-roles.js +7 -0
  117. package/dist/shared/app-roles.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/components/admin-navigation.tsx +57 -11
  120. package/src/components/app-list-row.tsx +1 -2
  121. package/src/components/app-open-actions.tsx +5 -114
  122. package/src/components/app-row-settings.tsx +54 -0
  123. package/src/components/automation-run-history-dialog.tsx +8 -4
  124. package/src/components/connected-app-card.tsx +0 -1
  125. package/src/components/create-app-popover.spec.tsx +1 -1
  126. package/src/components/create-app-popover.tsx +2 -2
  127. package/src/components/dispatch-control-plane.spec.tsx +36 -2
  128. package/src/components/dispatch-control-plane.tsx +83 -27
  129. package/src/components/dispatch-shell.tsx +7 -1
  130. package/src/components/docs-link.tsx +52 -0
  131. package/src/components/layout/Layout.spec.tsx +10 -0
  132. package/src/components/layout/Layout.tsx +50 -23
  133. package/src/components/messaging-setup-panel.tsx +10 -3
  134. package/src/components/usage-alerts-panel.tsx +445 -0
  135. package/src/components/workspace-app-card.spec.tsx +130 -9
  136. package/src/components/workspace-app-card.tsx +128 -71
  137. package/src/components/workspace-app-search.tsx +96 -0
  138. package/src/components/workspace-resource-effective-stack.tsx +10 -5
  139. package/src/components/workspace-template-card.spec.tsx +52 -23
  140. package/src/components/workspace-template-card.tsx +7 -10
  141. package/src/lib/workspace-app-layout.spec.ts +47 -0
  142. package/src/lib/workspace-app-layout.ts +121 -0
  143. package/src/lib/workspace-apps.test.ts +45 -1
  144. package/src/lib/workspace-apps.ts +72 -0
  145. package/src/routes/pages/$appId.tsx +3 -6
  146. package/src/routes/pages/agents.tsx +6 -3
  147. package/src/routes/pages/apps.$appId.tsx +6 -6
  148. package/src/routes/pages/apps.tsx +116 -62
  149. package/src/routes/pages/audit.tsx +28 -8
  150. package/src/routes/pages/dreams.tsx +92 -142
  151. package/src/routes/pages/integrations.tsx +147 -68
  152. package/src/routes/pages/metrics.tsx +8 -0
  153. package/src/routes/pages/operations.tsx +2 -2
  154. package/src/routes/pages/settings.tsx +14 -9
  155. package/src/routes/pages/thread-debug.tsx +18 -36
  156. package/src/routes/pages/vault.tsx +7 -2
  157. package/src/routes/pages/workspace.tsx +92 -56
  158. package/src/server/index.ts +4 -0
  159. package/src/server/lib/app-creation-store.spec.ts +90 -43
  160. package/src/server/lib/app-creation-store.ts +15 -61
  161. package/src/shared/app-roles.ts +8 -0
@@ -0,0 +1,121 @@
1
+ import {
2
+ orderChatFirstAppIds,
3
+ readChatFirstAppLayout,
4
+ writeChatFirstAppLayout,
5
+ type ChatFirstAppLayoutPreference,
6
+ } from "@agent-native/core/client/agent-chat";
7
+ import {
8
+ readClientAppState,
9
+ writeClientAppState,
10
+ } from "@agent-native/core/client/application-state";
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+
13
+ export const WORKSPACE_APP_LAYOUT_STATE_KEY = "chat-first-app-layout";
14
+
15
+ export type WorkspaceAppLayoutPersistenceError =
16
+ | "device"
17
+ | "workspace"
18
+ | "both";
19
+
20
+ export function normalizeWorkspaceAppLayout(
21
+ value: unknown,
22
+ ): ChatFirstAppLayoutPreference {
23
+ if (!value || typeof value !== "object") {
24
+ return { pinnedIds: [], orderedIds: [] };
25
+ }
26
+
27
+ const candidate = value as Partial<ChatFirstAppLayoutPreference>;
28
+ const ids = (input: unknown): string[] =>
29
+ Array.isArray(input)
30
+ ? input.filter(
31
+ (id): id is string => typeof id === "string" && id.trim().length > 0,
32
+ )
33
+ : [];
34
+
35
+ return {
36
+ pinnedIds: [...new Set(ids(candidate.pinnedIds))],
37
+ orderedIds: [...new Set(ids(candidate.orderedIds))],
38
+ };
39
+ }
40
+
41
+ export function orderWorkspaceApps<T extends { id: string }>(
42
+ apps: readonly T[],
43
+ layout: ChatFirstAppLayoutPreference,
44
+ ): T[] {
45
+ const appsById = new Map(apps.map((app) => [app.id, app]));
46
+ return orderChatFirstAppIds(
47
+ apps.map((app) => app.id),
48
+ layout,
49
+ )
50
+ .map((id) => appsById.get(id))
51
+ .filter((app): app is T => Boolean(app));
52
+ }
53
+
54
+ export function workspaceAppMatchesQuery(
55
+ app: { name: string; description?: string },
56
+ query: string,
57
+ ): boolean {
58
+ const normalizedQuery = query.trim().toLowerCase();
59
+ if (!normalizedQuery) return true;
60
+ return `${app.name} ${app.description ?? ""}`
61
+ .toLowerCase()
62
+ .includes(normalizedQuery);
63
+ }
64
+
65
+ export function toggleWorkspaceAppPinned(
66
+ layout: ChatFirstAppLayoutPreference,
67
+ appId: string,
68
+ ): ChatFirstAppLayoutPreference {
69
+ const pinnedIds = layout.pinnedIds.includes(appId)
70
+ ? layout.pinnedIds.filter((id) => id !== appId)
71
+ : [appId, ...layout.pinnedIds];
72
+ return { ...layout, pinnedIds };
73
+ }
74
+
75
+ export function useWorkspaceAppLayout() {
76
+ const [layout, setLayout] = useState<ChatFirstAppLayoutPreference>(() =>
77
+ readChatFirstAppLayout(),
78
+ );
79
+ const [persistenceError, setPersistenceError] =
80
+ useState<WorkspaceAppLayoutPersistenceError | null>(null);
81
+ const hydratedRef = useRef(false);
82
+ const localChangeRef = useRef(false);
83
+
84
+ useEffect(() => {
85
+ if (hydratedRef.current) return;
86
+ hydratedRef.current = true;
87
+ void readClientAppState<unknown>(WORKSPACE_APP_LAYOUT_STATE_KEY)
88
+ .then((value) => {
89
+ if (value !== null && !localChangeRef.current) {
90
+ setLayout(normalizeWorkspaceAppLayout(value));
91
+ }
92
+ })
93
+ .catch(() => {
94
+ // Device-local preferences remain usable when workspace state is unavailable.
95
+ });
96
+ }, []);
97
+
98
+ const persistLayout = useCallback((next: ChatFirstAppLayoutPreference) => {
99
+ localChangeRef.current = true;
100
+ setLayout(next);
101
+ const deviceResult = writeChatFirstAppLayout(next);
102
+ const deviceFailed = !deviceResult.ok;
103
+
104
+ void writeClientAppState(WORKSPACE_APP_LAYOUT_STATE_KEY, next)
105
+ .then(() => {
106
+ setPersistenceError(deviceFailed ? "device" : null);
107
+ })
108
+ .catch(() => {
109
+ setPersistenceError(deviceFailed ? "both" : "workspace");
110
+ });
111
+ }, []);
112
+
113
+ const togglePinned = useCallback(
114
+ (appId: string) => {
115
+ persistLayout(toggleWorkspaceAppPinned(layout, appId));
116
+ },
117
+ [layout, persistLayout],
118
+ );
119
+
120
+ return { layout, persistenceError, persistLayout, togglePinned };
121
+ }
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { workspaceAppEmbedTarget } from "./workspace-apps";
3
+ import {
4
+ workspaceAppDirectHref,
5
+ workspaceAppEmbedTarget,
6
+ } from "./workspace-apps";
4
7
 
5
8
  describe("workspaceAppEmbedTarget", () => {
6
9
  it("uses the app URL as the embed root when a mount path is also present", () => {
@@ -18,3 +21,44 @@ describe("workspaceAppEmbedTarget", () => {
18
21
  });
19
22
  });
20
23
  });
24
+
25
+ describe("workspaceAppDirectHref", () => {
26
+ it("joins an app-relative route onto an absolute app URL", () => {
27
+ expect(
28
+ workspaceAppDirectHref(
29
+ { path: "/atlas", url: "https://workspace.example.test/atlas" },
30
+ "/emails?status=failed#latest",
31
+ ),
32
+ ).toBe("https://workspace.example.test/atlas/emails?status=failed#latest");
33
+ });
34
+
35
+ it("does not duplicate a mount path already present in the target", () => {
36
+ expect(
37
+ workspaceAppDirectHref(
38
+ { path: "/atlas", url: "https://workspace.example.test/atlas" },
39
+ "/atlas",
40
+ ),
41
+ ).toBe("https://workspace.example.test/atlas");
42
+ });
43
+
44
+ it("resolves a mounted relative app path", () => {
45
+ expect(workspaceAppDirectHref({ path: "/atlas" }, "/emails")).toBe(
46
+ "/atlas/emails",
47
+ );
48
+ });
49
+
50
+ it("falls back to a safe mount path when the app URL is invalid", () => {
51
+ expect(
52
+ workspaceAppDirectHref(
53
+ { path: "/atlas", url: "javascript:alert(1)" },
54
+ "/",
55
+ ),
56
+ ).toBe("/atlas");
57
+ });
58
+
59
+ it("rejects an unsafe target path", () => {
60
+ expect(workspaceAppDirectHref({ path: "/atlas" }, "//evil.example")).toBe(
61
+ null,
62
+ );
63
+ });
64
+ });
@@ -27,6 +27,11 @@ export interface WorkspaceAppSummary {
27
27
  archived?: boolean;
28
28
  }
29
29
 
30
+ interface WorkspaceAppHrefSource {
31
+ path?: string | null;
32
+ url?: string | null;
33
+ }
34
+
30
35
  export function isDispatchWorkspaceAppId(appId: string): boolean {
31
36
  return appId.trim().toLowerCase() === "dispatch";
32
37
  }
@@ -80,6 +85,73 @@ export function workspaceAppEmbedTarget(
80
85
  return path.startsWith("/") ? { path } : path ? { url: path } : {};
81
86
  }
82
87
 
88
+ /**
89
+ * Resolve an app route without an embed ticket so the target can render its
90
+ * own error document when session setup fails.
91
+ */
92
+ export function workspaceAppDirectHref(
93
+ app: WorkspaceAppHrefSource,
94
+ targetPath: string,
95
+ ): string | null {
96
+ const target = targetPath.trim();
97
+ if (!target || !target.startsWith("/") || target.startsWith("//")) {
98
+ return null;
99
+ }
100
+
101
+ let targetUrl: URL;
102
+ try {
103
+ targetUrl = new URL(target, "https://agent-native.invalid");
104
+ } catch {
105
+ // coercion-ok: invalid relative target input has no safe href.
106
+ return null;
107
+ }
108
+ const targetPathname = targetUrl.pathname || "/";
109
+
110
+ let absoluteBase: URL | null = null;
111
+ const rawUrl = app.url?.trim();
112
+ if (rawUrl) {
113
+ try {
114
+ const parsedBase = new URL(rawUrl);
115
+ if (parsedBase.protocol === "http:" || parsedBase.protocol === "https:") {
116
+ absoluteBase = parsedBase;
117
+ }
118
+ // coercion-ok: invalid app URLs use the mounted path fallback below.
119
+ } catch {
120
+ absoluteBase = null;
121
+ }
122
+ }
123
+
124
+ const mountedPath = app.path?.trim();
125
+ const basePath = absoluteBase
126
+ ? absoluteBase.pathname
127
+ : mountedPath
128
+ ? `/${mountedPath.replace(/^[/\\]+/, "").split(/[?#]/, 1)[0]}`
129
+ : null;
130
+ if (!basePath) return null;
131
+
132
+ const normalizedBasePath = basePath.replace(/\/+$/, "") || "/";
133
+ const targetIsMountedPath =
134
+ normalizedBasePath !== "/" &&
135
+ (targetPathname === normalizedBasePath ||
136
+ targetPathname.startsWith(`${normalizedBasePath}/`));
137
+ const resolvedPath = targetIsMountedPath
138
+ ? targetPathname
139
+ : normalizedBasePath === "/"
140
+ ? targetPathname
141
+ : targetPathname === "/"
142
+ ? normalizedBasePath
143
+ : `${normalizedBasePath}/${targetPathname.replace(/^\/+/, "")}`;
144
+
145
+ if (absoluteBase) {
146
+ absoluteBase.pathname = resolvedPath;
147
+ absoluteBase.search = targetUrl.search;
148
+ absoluteBase.hash = targetUrl.hash;
149
+ return absoluteBase.toString();
150
+ }
151
+
152
+ return `${resolvedPath}${targetUrl.search}${targetUrl.hash}`;
153
+ }
154
+
83
155
  export function isPendingBuilderHref(app: WorkspaceAppSummary): boolean {
84
156
  return app.status === "pending" && !!app.builderUrl;
85
157
  }
@@ -176,11 +176,6 @@ export default function WorkspaceAppCatchAllRoute() {
176
176
  <span className="font-mono text-foreground">{app.path}</span>{" "}
177
177
  {t("dispatch.pages.appBuildingSuffix")}
178
178
  </p>
179
- {app.branchName ? (
180
- <p className="text-xs text-muted-foreground">
181
- {t("dispatch.pages.branch", { branch: app.branchName })}
182
- </p>
183
- ) : null}
184
179
  {app.builderUrl ? (
185
180
  <Button asChild>
186
181
  <a
@@ -191,7 +186,9 @@ export default function WorkspaceAppCatchAllRoute() {
191
186
  target="_blank"
192
187
  rel="noreferrer"
193
188
  >
194
- {t("dispatch.pages.openBuilderBranch")}
189
+ {t("dispatch.pages.openBuilderBranch", {
190
+ defaultValue: "Open in Builder",
191
+ })}
195
192
  <IconArrowUpRight size={15} className="ml-1.5" />
196
193
  </a>
197
194
  </Button>
@@ -15,6 +15,7 @@ import {
15
15
  import { DispatchShell } from "../../components/dispatch-shell";
16
16
  import { Button } from "../../components/ui/button";
17
17
  import { Input } from "../../components/ui/input";
18
+ import { Skeleton } from "../../components/ui/skeleton";
18
19
  import { Switch } from "../../components/ui/switch";
19
20
 
20
21
  export function meta() {
@@ -123,9 +124,11 @@ function DispatchMcpAccessPanel() {
123
124
  : t("dispatch.pages.selectedApps")}
124
125
  </div>
125
126
  <div className="text-xs text-muted-foreground">
126
- {isLoading
127
- ? t("dispatch.pages.loading")
128
- : t("dispatch.pages.grantedCount", { count: grantedCount })}
127
+ {isLoading ? (
128
+ <Skeleton className="h-3 w-16" />
129
+ ) : (
130
+ t("dispatch.pages.grantedCount", { count: grantedCount })
131
+ )}
129
132
  </div>
130
133
  </div>
131
134
  <Switch
@@ -15,6 +15,7 @@ import { Skeleton } from "../../components/ui/skeleton";
15
15
  import { Spinner } from "../../components/ui/spinner";
16
16
  import { isEmbedSessionExpiredMessage } from "../../lib/embed-session-recovery";
17
17
  import {
18
+ workspaceAppDirectHref,
18
19
  workspaceAppEmbedTarget,
19
20
  workspaceAppHref,
20
21
  type WorkspaceAppSummary,
@@ -82,6 +83,8 @@ export default function WorkspaceAppRoute() {
82
83
  })
83
84
  .catch((cause: unknown) => {
84
85
  if (!cancelled) {
86
+ const directAppUrl = workspaceAppDirectHref(app, "/");
87
+ if (directAppUrl) setEmbedUrl(directAppUrl);
85
88
  setEmbedError(
86
89
  cause instanceof Error ? cause : new Error(String(cause)),
87
90
  );
@@ -187,11 +190,6 @@ export default function WorkspaceAppRoute() {
187
190
  <span className="font-mono text-foreground">{app.path}</span>{" "}
188
191
  {t("dispatch.pages.appBuildingSuffix")}
189
192
  </p>
190
- {app.branchName ? (
191
- <p className="text-xs text-muted-foreground">
192
- {t("dispatch.pages.branch", { branch: app.branchName })}
193
- </p>
194
- ) : null}
195
193
  {app.builderUrl ? (
196
194
  <Button asChild>
197
195
  <a
@@ -202,7 +200,9 @@ export default function WorkspaceAppRoute() {
202
200
  target="_blank"
203
201
  rel="noreferrer"
204
202
  >
205
- {t("dispatch.pages.openBuilderBranch")}
203
+ {t("dispatch.pages.openBuilderBranch", {
204
+ defaultValue: "Open in Builder",
205
+ })}
206
206
  </a>
207
207
  </Button>
208
208
  ) : null}
@@ -27,12 +27,21 @@ import {
27
27
  } from "../../components/ui/collapsible";
28
28
  import { Skeleton } from "../../components/ui/skeleton";
29
29
  import { WorkspaceAppCard } from "../../components/workspace-app-card";
30
+ import {
31
+ WorkspaceAppSearch,
32
+ WorkspaceAppSearchEmpty,
33
+ } from "../../components/workspace-app-search";
30
34
  import type {
31
35
  CuratedWorkspaceTemplatesResult,
32
36
  WorkspaceTemplateLabels,
33
37
  } from "../../components/workspace-template-card";
34
38
  import type { ConnectedAppSummary } from "../../lib/other-apps";
35
39
  import { cn } from "../../lib/utils";
40
+ import {
41
+ orderWorkspaceApps,
42
+ useWorkspaceAppLayout,
43
+ workspaceAppMatchesQuery,
44
+ } from "../../lib/workspace-app-layout";
36
45
  import {
37
46
  isWorkspaceAppVisibleInDefaultLaunchers,
38
47
  type WorkspaceAppSummary,
@@ -57,6 +66,8 @@ function AppsRoute() {
57
66
  const t = useT();
58
67
  const [showPending, setShowPending] = useState(false);
59
68
  const [showHidden, setShowHidden] = useState(false);
69
+ const [searchQuery, setSearchQuery] = useState("");
70
+ const { layout, persistenceError, togglePinned } = useWorkspaceAppLayout();
60
71
  const appsQuery = useActionQuery("list-workspace-apps", {
61
72
  includeAgentCards: false,
62
73
  includeArchived: true,
@@ -81,6 +92,22 @@ function AppsRoute() {
81
92
  const activeApps = visibleApps.filter((app) => app.status !== "pending");
82
93
  const pendingApps = visibleApps.filter((app) => app.status === "pending");
83
94
  const archivedApps = allApps.filter((app) => app.archived);
95
+ const orderedActiveApps = orderWorkspaceApps(activeApps, layout);
96
+ const orderedPendingApps = orderWorkspaceApps(pendingApps, layout);
97
+ const orderedArchivedApps = orderWorkspaceApps(archivedApps, layout);
98
+ const filteredActiveApps = orderedActiveApps.filter((app) =>
99
+ workspaceAppMatchesQuery(app, searchQuery),
100
+ );
101
+ const filteredPendingApps = orderedPendingApps.filter((app) =>
102
+ workspaceAppMatchesQuery(app, searchQuery),
103
+ );
104
+ const filteredArchivedApps = orderedArchivedApps.filter((app) =>
105
+ workspaceAppMatchesQuery(app, searchQuery),
106
+ );
107
+ const hasSearchResults =
108
+ filteredActiveApps.length > 0 ||
109
+ filteredPendingApps.length > 0 ||
110
+ filteredArchivedApps.length > 0;
84
111
  const showAppSkeletons = appsLoading && allApps.length === 0;
85
112
  const templateLabels: WorkspaceTemplateLabels = {
86
113
  appId: t("dispatch.pages.remixAppIdLabel"),
@@ -111,7 +138,7 @@ function AppsRoute() {
111
138
  >
112
139
  <div className="space-y-8">
113
140
  <section className="space-y-3">
114
- <div className="flex flex-wrap items-end justify-between gap-3">
141
+ <div className="flex flex-wrap items-center justify-between gap-3">
115
142
  <div className="flex min-w-0 items-start gap-2">
116
143
  <IconApps
117
144
  size={16}
@@ -121,36 +148,44 @@ function AppsRoute() {
121
148
  <h2 className="truncate text-sm font-semibold text-foreground">
122
149
  {t("dispatch.pages.yourApps", { defaultValue: "Your apps" })}
123
150
  </h2>
124
- <p className="mt-0.5 text-xs text-muted-foreground">
125
- {t("dispatch.pages.activeCount", {
126
- count: activeApps.length,
127
- })}
128
- {pendingApps.length > 0
129
- ? ` · ${t("dispatch.pages.pendingCount", {
130
- count: pendingApps.length,
131
- })}`
132
- : ""}
133
- {archivedApps.length > 0
134
- ? ` · ${t("dispatch.pages.hiddenCount", {
135
- count: archivedApps.length,
136
- })}`
137
- : ""}
138
- </p>
139
151
  </div>
140
152
  </div>
141
- {activeApps.length > 0 || pendingApps.length > 0 ? (
142
- <CreateAppPopover
143
- align="end"
144
- trigger={
145
- <Button size="sm" variant="outline">
146
- <IconPlus size={15} className="mr-1.5" />
147
- {t("dispatch.pages.addApp", { defaultValue: "Add app" })}
148
- </Button>
149
- }
150
- />
151
- ) : null}
153
+ <div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
154
+ {!showAppSkeletons && allApps.length > 0 ? (
155
+ <WorkspaceAppSearch
156
+ className="w-full sm:w-[250px]"
157
+ query={searchQuery}
158
+ onQueryChange={setSearchQuery}
159
+ />
160
+ ) : null}
161
+ {activeApps.length > 0 || pendingApps.length > 0 ? (
162
+ <CreateAppPopover
163
+ align="end"
164
+ trigger={
165
+ <Button size="sm" variant="outline">
166
+ <IconPlus size={15} className="mr-1.5" />
167
+ {t("dispatch.pages.addApp", {
168
+ defaultValue: "Add app",
169
+ })}
170
+ </Button>
171
+ }
172
+ />
173
+ ) : null}
174
+ </div>
152
175
  </div>
153
176
 
177
+ {!showAppSkeletons && allApps.length > 0 && persistenceError ? (
178
+ <p className="text-xs text-muted-foreground" role="status">
179
+ {persistenceError === "both"
180
+ ? t("dispatch.pages.appPinSaveFailed", {
181
+ defaultValue: "App pins could not be saved.",
182
+ })
183
+ : t("dispatch.pages.appPinSavedLocally", {
184
+ defaultValue: "App pins are saved on this device only.",
185
+ })}
186
+ </p>
187
+ ) : null}
188
+
154
189
  {appsQuery.isError ? (
155
190
  <ActionQueryError
156
191
  error={appsQuery.error}
@@ -158,25 +193,36 @@ function AppsRoute() {
158
193
  />
159
194
  ) : showAppSkeletons ? (
160
195
  <AppsSkeletonGrid />
161
- ) : activeApps.length > 0 ? (
196
+ ) : !hasSearchResults && searchQuery.trim() ? (
197
+ <WorkspaceAppSearchEmpty
198
+ query={searchQuery}
199
+ onClear={() => setSearchQuery("")}
200
+ />
201
+ ) : filteredActiveApps.length > 0 ? (
162
202
  <AppList className={APP_LIST_GRID_CLASS}>
163
- {activeApps.map((app) => (
203
+ {filteredActiveApps.map((app) => (
164
204
  <WorkspaceAppCard
165
205
  key={app.id}
166
206
  app={app}
167
207
  className={APP_LIST_GRID_ROW_CLASS}
208
+ isPinned={layout.pinnedIds.includes(app.id)}
209
+ onTogglePinned={() => togglePinned(app.id)}
168
210
  />
169
211
  ))}
170
212
  </AppList>
171
- ) : pendingApps.length > 0 ? (
213
+ ) : searchQuery.trim() ? null : pendingApps.length > 0 ? (
172
214
  <EmptyActiveAppsState />
173
215
  ) : (
174
216
  <EmptyAppsState />
175
217
  )}
176
218
  </section>
177
219
 
178
- {pendingApps.length > 0 ? (
179
- <Collapsible open={showPending} onOpenChange={setShowPending}>
220
+ {pendingApps.length > 0 &&
221
+ (!searchQuery.trim() || filteredPendingApps.length > 0) ? (
222
+ <Collapsible
223
+ open={showPending || Boolean(searchQuery.trim())}
224
+ onOpenChange={setShowPending}
225
+ >
180
226
  <section className="space-y-3 border-t pt-6">
181
227
  <div className="flex flex-wrap items-center justify-between gap-3">
182
228
  <div className="flex min-w-0 items-center gap-2">
@@ -202,14 +248,15 @@ function AppsRoute() {
202
248
  size="sm"
203
249
  className="gap-1.5"
204
250
  >
205
- {showPending
251
+ {showPending || Boolean(searchQuery.trim())
206
252
  ? t("dispatch.pages.hidePendingApps")
207
253
  : t("dispatch.pages.showPendingApps")}
208
254
  <IconChevronDown
209
255
  size={14}
210
256
  className={cn(
211
257
  "transition-transform",
212
- showPending && "rotate-180",
258
+ (showPending || Boolean(searchQuery.trim())) &&
259
+ "rotate-180",
213
260
  )}
214
261
  />
215
262
  </Button>
@@ -217,7 +264,7 @@ function AppsRoute() {
217
264
  </div>
218
265
  <CollapsibleContent>
219
266
  <AppList className={APP_LIST_GRID_CLASS}>
220
- {pendingApps.map((app) => (
267
+ {filteredPendingApps.map((app) => (
221
268
  <WorkspaceAppCard
222
269
  key={app.id}
223
270
  app={app}
@@ -230,31 +277,37 @@ function AppsRoute() {
230
277
  </Collapsible>
231
278
  ) : null}
232
279
 
233
- <OtherAppsSection
234
- templates={
235
- curatedTemplatesQuery.data as
236
- | CuratedWorkspaceTemplatesResult
237
- | undefined
238
- }
239
- connectedApps={
240
- connectedAppsQuery.data as ConnectedAppSummary[] | undefined
241
- }
242
- workspaceApps={allApps}
243
- templatesLoading={curatedTemplatesQuery.isLoading}
244
- connectedAppsLoading={connectedAppsQuery.isLoading}
245
- templatesError={curatedTemplatesQuery.error}
246
- connectedAppsError={connectedAppsQuery.error}
247
- onRetryTemplates={() => void curatedTemplatesQuery.refetch()}
248
- onRetryConnectedApps={() => void connectedAppsQuery.refetch()}
249
- templateLabels={templateLabels}
250
- onRemixSuccess={() => {
251
- void appsQuery.refetch();
252
- void curatedTemplatesQuery.refetch();
253
- }}
254
- />
280
+ {!searchQuery.trim() ? (
281
+ <OtherAppsSection
282
+ templates={
283
+ curatedTemplatesQuery.data as
284
+ | CuratedWorkspaceTemplatesResult
285
+ | undefined
286
+ }
287
+ connectedApps={
288
+ connectedAppsQuery.data as ConnectedAppSummary[] | undefined
289
+ }
290
+ workspaceApps={allApps}
291
+ templatesLoading={curatedTemplatesQuery.isLoading}
292
+ connectedAppsLoading={connectedAppsQuery.isLoading}
293
+ templatesError={curatedTemplatesQuery.error}
294
+ connectedAppsError={connectedAppsQuery.error}
295
+ onRetryTemplates={() => void curatedTemplatesQuery.refetch()}
296
+ onRetryConnectedApps={() => void connectedAppsQuery.refetch()}
297
+ templateLabels={templateLabels}
298
+ onRemixSuccess={() => {
299
+ void appsQuery.refetch();
300
+ void curatedTemplatesQuery.refetch();
301
+ }}
302
+ />
303
+ ) : null}
255
304
 
256
- {archivedApps.length > 0 ? (
257
- <Collapsible open={showHidden} onOpenChange={setShowHidden}>
305
+ {archivedApps.length > 0 &&
306
+ (!searchQuery.trim() || filteredArchivedApps.length > 0) ? (
307
+ <Collapsible
308
+ open={showHidden || Boolean(searchQuery.trim())}
309
+ onOpenChange={setShowHidden}
310
+ >
258
311
  <section className="space-y-3">
259
312
  <div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
260
313
  <div className="flex min-w-0 items-center gap-2">
@@ -280,14 +333,15 @@ function AppsRoute() {
280
333
  size="sm"
281
334
  className="gap-1.5"
282
335
  >
283
- {showHidden
336
+ {showHidden || Boolean(searchQuery.trim())
284
337
  ? t("dispatch.pages.hide")
285
338
  : t("dispatch.pages.show")}
286
339
  <IconChevronDown
287
340
  size={14}
288
341
  className={cn(
289
342
  "transition-transform",
290
- showHidden && "rotate-180",
343
+ (showHidden || Boolean(searchQuery.trim())) &&
344
+ "rotate-180",
291
345
  )}
292
346
  />
293
347
  </Button>
@@ -295,7 +349,7 @@ function AppsRoute() {
295
349
  </div>
296
350
  <CollapsibleContent>
297
351
  <AppList className={APP_LIST_GRID_CLASS}>
298
- {archivedApps.map((app) => (
352
+ {filteredArchivedApps.map((app) => (
299
353
  <WorkspaceAppCard
300
354
  key={app.id}
301
355
  app={app}