@agent-native/dispatch 0.15.25 → 0.15.26

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.15.25",
3
+ "version": "0.15.26",
4
4
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -84,7 +84,7 @@
84
84
  "tailwind-merge": "^3.5.0",
85
85
  "vaul": "^1.1.2",
86
86
  "zod": "^4.3.6",
87
- "@agent-native/toolkit": "0.10.8"
87
+ "@agent-native/toolkit": "0.10.9"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@react-router/dev": "^8.1.0",
@@ -98,7 +98,7 @@
98
98
  "typescript-7": "npm:typescript@^7.0.2",
99
99
  "vite": "8.1.0",
100
100
  "vitest": "^4.1.5",
101
- "@agent-native/core": "0.124.4"
101
+ "@agent-native/core": "0.127.1"
102
102
  },
103
103
  "peerDependencies": {
104
104
  "@agent-native/core": ">=0.8.0",
@@ -12,6 +12,7 @@ import { getRequestUserEmail } from "@agent-native/core/server";
12
12
  import {
13
13
  discoverAgents,
14
14
  getBuiltinAgents,
15
+ normalizeAgentId,
15
16
  shouldIncludeRemoteAgentManifest,
16
17
  } from "@agent-native/core/server/agent-discovery";
17
18
  import { z } from "zod";
@@ -58,8 +59,12 @@ export default defineAction({
58
59
  const manifest = parseRemoteAgentManifest(full.content, resource.path);
59
60
  if (!manifest) continue;
60
61
  if (!shouldIncludeRemoteAgentManifest(manifest, "dispatch")) continue;
61
- if (builtinIds.has(manifest.id)) continue;
62
- customById.set(manifest.id, {
62
+ // discoverAgents keys agents by the normalized id (image/images/asset
63
+ // all collapse to assets). Keying this map by the raw manifest id makes
64
+ // the id lookups below miss, so the same agent lands in the list twice.
65
+ const manifestId = normalizeAgentId(manifest.id);
66
+ if (builtinIds.has(manifestId)) continue;
67
+ customById.set(manifestId, {
63
68
  resourceId: resource.id,
64
69
  path: resource.path,
65
70
  scope: resource.owner === SHARED_OWNER ? "shared" : "personal",
@@ -6,8 +6,13 @@ import { PromptComposer } from "@agent-native/core/client/composer";
6
6
  import { useActionQuery } from "@agent-native/core/client/hooks";
7
7
  import { isInBuilderFrame } from "@agent-native/core/client/host";
8
8
  import { useT } from "@agent-native/core/client/i18n";
9
- import { IconArrowUpRight } from "@tabler/icons-react";
9
+ import {
10
+ IconArrowUpRight,
11
+ IconChevronDown,
12
+ IconClockHour4,
13
+ } from "@tabler/icons-react";
10
14
  import type { ReactNode } from "react";
15
+ import { useState } from "react";
11
16
  import { Link, useNavigate } from "react-router";
12
17
 
13
18
  import { submitOverviewPrompt } from "../lib/overview-chat";
@@ -16,6 +21,11 @@ import { ActionQueryError } from "./action-query-error";
16
21
  import { CreateAppPopover } from "./create-app-popover";
17
22
  import { useSetPageTitle } from "./layout/HeaderActions";
18
23
  import { Button } from "./ui/button";
24
+ import {
25
+ Collapsible,
26
+ CollapsibleContent,
27
+ CollapsibleTrigger,
28
+ } from "./ui/collapsible";
19
29
  import { Skeleton } from "./ui/skeleton";
20
30
  import { WorkspaceAppCard } from "./workspace-app-card";
21
31
 
@@ -135,8 +145,13 @@ function AppsPanel({
135
145
  apps: WorkspaceAppSummary[];
136
146
  isLoading: boolean;
137
147
  }) {
148
+ const t = useT();
149
+ const [showPending, setShowPending] = useState(false);
138
150
  const visibleApps = apps.filter((app) => !app.isDispatch && !app.archived);
139
- const showSkeletons = isLoading && visibleApps.length === 0;
151
+ const activeApps = visibleApps.filter((app) => app.status !== "pending");
152
+ const pendingApps = visibleApps.filter((app) => app.status === "pending");
153
+ const showSkeletons =
154
+ isLoading && activeApps.length === 0 && pendingApps.length === 0;
140
155
 
141
156
  return (
142
157
  <section className="flex flex-col gap-3">
@@ -161,15 +176,66 @@ function AppsPanel({
161
176
  </div>
162
177
  ))}
163
178
  </div>
164
- ) : visibleApps.length > 0 ? (
179
+ ) : activeApps.length > 0 ? (
165
180
  <div className="grid gap-3 md:grid-cols-2">
166
- {visibleApps.map((app) => (
181
+ {activeApps.map((app) => (
167
182
  <WorkspaceAppCard key={app.id} app={app} className="min-h-32" />
168
183
  ))}
169
184
  </div>
170
185
  ) : (
171
186
  <CreateAppPopover />
172
187
  )}
188
+ {pendingApps.length > 0 ? (
189
+ <Collapsible open={showPending} onOpenChange={setShowPending}>
190
+ <div className="space-y-3 border-t pt-3">
191
+ <div className="flex flex-wrap items-center justify-between gap-3">
192
+ <div className="flex min-w-0 items-center gap-2">
193
+ <IconClockHour4
194
+ size={15}
195
+ className="shrink-0 text-muted-foreground"
196
+ />
197
+ <div className="min-w-0">
198
+ <h3 className="text-xs font-semibold text-foreground">
199
+ {t("dispatch.pages.pendingApps")}
200
+ </h3>
201
+ <p className="text-xs text-muted-foreground">
202
+ {t("dispatch.pages.pendingAppsDescription", {
203
+ count: pendingApps.length,
204
+ })}
205
+ </p>
206
+ </div>
207
+ </div>
208
+ <CollapsibleTrigger asChild>
209
+ <Button
210
+ type="button"
211
+ variant="ghost"
212
+ size="sm"
213
+ className="h-7 gap-1.5 px-2 text-xs"
214
+ >
215
+ {showPending
216
+ ? t("dispatch.pages.hidePendingApps")
217
+ : t("dispatch.pages.showPendingApps")}
218
+ <IconChevronDown
219
+ size={13}
220
+ className={showPending ? "rotate-180" : undefined}
221
+ />
222
+ </Button>
223
+ </CollapsibleTrigger>
224
+ </div>
225
+ <CollapsibleContent>
226
+ <div className="grid gap-3 md:grid-cols-2">
227
+ {pendingApps.map((app) => (
228
+ <WorkspaceAppCard
229
+ key={app.id}
230
+ app={app}
231
+ className="min-h-32"
232
+ />
233
+ ))}
234
+ </div>
235
+ </CollapsibleContent>
236
+ </div>
237
+ </Collapsible>
238
+ ) : null}
173
239
  </section>
174
240
  );
175
241
  }
@@ -17,6 +17,13 @@ vi.mock("@agent-native/core/client/hooks", () => ({
17
17
  }),
18
18
  }));
19
19
 
20
+ vi.mock("@agent-native/core/client/i18n", () => ({
21
+ useFormatters: () => ({
22
+ formatDate: (value: string) => value,
23
+ }),
24
+ useT: () => (key: string) => key,
25
+ }));
26
+
20
27
  describe("WorkspaceAppCard", () => {
21
28
  let container: HTMLDivElement;
22
29
  let root: Root;
@@ -140,4 +147,40 @@ describe("WorkspaceAppCard", () => {
140
147
  expect(appLink?.getAttribute("target")).toBe("_blank");
141
148
  expect(appLink?.getAttribute("rel")).toBe("noreferrer");
142
149
  });
150
+
151
+ it("shows ownership metadata in the more menu", async () => {
152
+ await act(async () => {
153
+ root.render(
154
+ <TooltipProvider>
155
+ <WorkspaceAppCard
156
+ app={{
157
+ id: "analytics",
158
+ name: "Analytics",
159
+ path: "/analytics",
160
+ createdAt: "2026-07-28T12:00:00.000Z",
161
+ createdBy: "creator@example.com",
162
+ owner: "owner@example.com",
163
+ teams: ["Growth", "Operations"],
164
+ status: "ready",
165
+ }}
166
+ />
167
+ </TooltipProvider>,
168
+ );
169
+ });
170
+
171
+ const moreButton = container.querySelector<HTMLButtonElement>(
172
+ 'button[aria-label="More actions for Analytics"]',
173
+ );
174
+ expect(moreButton).not.toBeNull();
175
+
176
+ await act(async () => {
177
+ moreButton?.dispatchEvent(
178
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
179
+ );
180
+ });
181
+
182
+ expect(document.body.textContent).toContain("creator@example.com");
183
+ expect(document.body.textContent).toContain("owner@example.com");
184
+ expect(document.body.textContent).toContain("Growth, Operations");
185
+ });
143
186
  });
@@ -2,7 +2,9 @@ import {
2
2
  useActionMutation,
3
3
  useActionQuery,
4
4
  } from "@agent-native/core/client/hooks";
5
+ import { useFormatters, useT } from "@agent-native/core/client/i18n";
5
6
  import {
7
+ IconCalendar,
6
8
  IconChevronDown,
7
9
  IconChevronRight,
8
10
  IconClockHour4,
@@ -13,6 +15,8 @@ import {
13
15
  IconFileText,
14
16
  IconWorld,
15
17
  IconTrash,
18
+ IconUser,
19
+ IconUsersGroup,
16
20
  } from "@tabler/icons-react";
17
21
  import { useEffect, useState, type FormEvent } from "react";
18
22
  import { toast } from "sonner";
@@ -40,6 +44,8 @@ import {
40
44
  DropdownMenu,
41
45
  DropdownMenuContent,
42
46
  DropdownMenuItem,
47
+ DropdownMenuLabel,
48
+ DropdownMenuSeparator,
43
49
  DropdownMenuTrigger,
44
50
  } from "./ui/dropdown-menu";
45
51
  import { Input } from "./ui/input";
@@ -58,6 +64,8 @@ export function WorkspaceAppCard({
58
64
  app: WorkspaceAppSummary;
59
65
  className?: string;
60
66
  }) {
67
+ const t = useT();
68
+ const { formatDate } = useFormatters();
61
69
  const href = workspaceAppHref(app);
62
70
  const openInNewTab = isPendingBuilderHref(app);
63
71
  const isPending = app.status === "pending";
@@ -215,7 +223,7 @@ export function WorkspaceAppCard({
215
223
  </TooltipTrigger>
216
224
  <TooltipContent>More actions</TooltipContent>
217
225
  </Tooltip>
218
- <DropdownMenuContent align="end" className="w-44">
226
+ <DropdownMenuContent align="end" className="w-72">
219
227
  <DropdownMenuItem
220
228
  onSelect={(event) => {
221
229
  event.preventDefault();
@@ -244,6 +252,20 @@ export function WorkspaceAppCard({
244
252
  Hide from list
245
253
  </DropdownMenuItem>
246
254
  )}
255
+ <DropdownMenuSeparator />
256
+ <DropdownMenuLabel className="font-normal">
257
+ <WorkspaceAppMetadata
258
+ app={app}
259
+ formatDate={formatDate}
260
+ labels={{
261
+ created: t("agents.dashboardMetadataCreated"),
262
+ createdBy: t("agents.dashboardMetadataCreatedBy"),
263
+ owner: t("dispatch.pages.appMetadataOwner"),
264
+ teams: t("dispatch.pages.appMetadataTeams"),
265
+ notTracked: t("agents.notTracked"),
266
+ }}
267
+ />
268
+ </DropdownMenuLabel>
247
269
  </DropdownMenuContent>
248
270
  </DropdownMenu>
249
271
  </div>
@@ -293,6 +315,80 @@ export function WorkspaceAppCard({
293
315
  );
294
316
  }
295
317
 
318
+ function WorkspaceAppMetadata({
319
+ app,
320
+ formatDate,
321
+ labels,
322
+ }: {
323
+ app: WorkspaceAppSummary;
324
+ formatDate: ReturnType<typeof useFormatters>["formatDate"];
325
+ labels: {
326
+ created: string;
327
+ createdBy: string;
328
+ owner: string;
329
+ teams: string;
330
+ notTracked: string;
331
+ };
332
+ }) {
333
+ function formatMetadataDate(value: string | null | undefined): string {
334
+ if (!value) return labels.notTracked;
335
+ try {
336
+ return formatDate(value, {
337
+ year: "numeric",
338
+ month: "short",
339
+ day: "numeric",
340
+ hour: "numeric",
341
+ minute: "2-digit",
342
+ });
343
+ } catch {
344
+ return value;
345
+ }
346
+ }
347
+
348
+ return (
349
+ <div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
350
+ <WorkspaceAppMetadataRow
351
+ icon={IconCalendar}
352
+ label={labels.created}
353
+ value={formatMetadataDate(app.createdAt)}
354
+ />
355
+ <WorkspaceAppMetadataRow
356
+ icon={IconUser}
357
+ label={labels.createdBy}
358
+ value={app.createdBy || labels.notTracked}
359
+ />
360
+ <WorkspaceAppMetadataRow
361
+ icon={IconUser}
362
+ label={labels.owner}
363
+ value={app.owner || labels.notTracked}
364
+ />
365
+ <WorkspaceAppMetadataRow
366
+ icon={IconUsersGroup}
367
+ label={labels.teams}
368
+ value={app.teams?.join(", ") || labels.notTracked}
369
+ />
370
+ </div>
371
+ );
372
+ }
373
+
374
+ function WorkspaceAppMetadataRow({
375
+ icon: Icon,
376
+ label,
377
+ value,
378
+ }: {
379
+ icon: typeof IconCalendar;
380
+ label: string;
381
+ value: string;
382
+ }) {
383
+ return (
384
+ <div className="flex items-start gap-1.5">
385
+ <Icon className="mt-0.5 h-3 w-3 shrink-0" />
386
+ <span className="shrink-0">{label}</span>
387
+ <span className="min-w-0 truncate text-foreground/80">{value}</span>
388
+ </div>
389
+ );
390
+ }
391
+
296
392
  function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
297
393
  const [open, setOpen] = useState(false);
298
394
  const [inspectedResourceId, setInspectedResourceId] = useState<string | null>(
@@ -15,6 +15,9 @@ export interface WorkspaceAppSummary {
15
15
  builderUrl?: string | null;
16
16
  branchName?: string | null;
17
17
  createdAt?: string | null;
18
+ createdBy?: string | null;
19
+ owner?: string | null;
20
+ teams?: string[];
18
21
  agentCardUrl?: string | null;
19
22
  agentCardReachable?: boolean;
20
23
  a2aEndpointUrl?: string | null;
@@ -5,6 +5,7 @@ import {
5
5
  IconApps,
6
6
  IconArrowUpRight,
7
7
  IconChevronDown,
8
+ IconClockHour4,
8
9
  IconEyeOff,
9
10
  IconPlus,
10
11
  IconStack2,
@@ -46,6 +47,7 @@ interface WorkspaceInfo {
46
47
 
47
48
  export default function AppsRoute() {
48
49
  const t = useT();
50
+ const [showPending, setShowPending] = useState(false);
49
51
  const [showHidden, setShowHidden] = useState(false);
50
52
  const appsQuery = useActionQuery("list-workspace-apps", {
51
53
  includeAgentCards: false,
@@ -68,6 +70,8 @@ export default function AppsRoute() {
68
70
  (app) => !app.isDispatch,
69
71
  );
70
72
  const visibleApps = allApps.filter((app) => !app.archived);
73
+ const activeApps = visibleApps.filter((app) => app.status !== "pending");
74
+ const pendingApps = visibleApps.filter((app) => app.status === "pending");
71
75
  const archivedApps = allApps.filter((app) => app.archived);
72
76
  const otherApps = filterOtherApps(
73
77
  (connectedAppsQuery.data || []) as ConnectedAppSummary[],
@@ -118,8 +122,13 @@ export default function AppsRoute() {
118
122
  </h2>
119
123
  <p className="mt-0.5 text-xs text-muted-foreground">
120
124
  {t("dispatch.pages.activeCount", {
121
- count: visibleApps.length,
125
+ count: activeApps.length,
122
126
  })}
127
+ {pendingApps.length > 0
128
+ ? ` · ${t("dispatch.pages.pendingCount", {
129
+ count: pendingApps.length,
130
+ })}`
131
+ : ""}
123
132
  {archivedApps.length > 0
124
133
  ? ` · ${t("dispatch.pages.hiddenCount", {
125
134
  count: archivedApps.length,
@@ -128,7 +137,7 @@ export default function AppsRoute() {
128
137
  </p>
129
138
  </div>
130
139
  </div>
131
- {visibleApps.length > 0 ? (
140
+ {activeApps.length > 0 || pendingApps.length > 0 ? (
132
141
  <CreateAppPopover
133
142
  align="end"
134
143
  trigger={
@@ -148,17 +157,74 @@ export default function AppsRoute() {
148
157
  />
149
158
  ) : showAppSkeletons ? (
150
159
  <AppsSkeletonGrid />
151
- ) : visibleApps.length > 0 ? (
160
+ ) : activeApps.length > 0 ? (
152
161
  <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
153
- {visibleApps.map((app) => (
162
+ {activeApps.map((app) => (
154
163
  <WorkspaceAppCard key={app.id} app={app} className="h-full" />
155
164
  ))}
156
165
  </div>
166
+ ) : pendingApps.length > 0 ? (
167
+ <EmptyActiveAppsState />
157
168
  ) : (
158
169
  <EmptyAppsState />
159
170
  )}
160
171
  </section>
161
172
 
173
+ {pendingApps.length > 0 ? (
174
+ <Collapsible open={showPending} onOpenChange={setShowPending}>
175
+ <section className="space-y-3 border-t pt-4">
176
+ <div className="flex flex-wrap items-center justify-between gap-3">
177
+ <div className="flex min-w-0 items-center gap-2">
178
+ <IconClockHour4
179
+ size={16}
180
+ className="shrink-0 text-muted-foreground"
181
+ />
182
+ <div className="min-w-0">
183
+ <h2 className="text-sm font-semibold text-foreground">
184
+ {t("dispatch.pages.pendingApps")}
185
+ </h2>
186
+ <p className="text-xs text-muted-foreground">
187
+ {t("dispatch.pages.pendingAppsDescription", {
188
+ count: pendingApps.length,
189
+ })}
190
+ </p>
191
+ </div>
192
+ </div>
193
+ <CollapsibleTrigger asChild>
194
+ <Button
195
+ type="button"
196
+ variant="outline"
197
+ size="sm"
198
+ className="gap-1.5"
199
+ >
200
+ {showPending
201
+ ? t("dispatch.pages.hidePendingApps")
202
+ : t("dispatch.pages.showPendingApps")}
203
+ <IconChevronDown
204
+ size={14}
205
+ className={cn(
206
+ "transition-transform",
207
+ showPending && "rotate-180",
208
+ )}
209
+ />
210
+ </Button>
211
+ </CollapsibleTrigger>
212
+ </div>
213
+ <CollapsibleContent>
214
+ <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
215
+ {pendingApps.map((app) => (
216
+ <WorkspaceAppCard
217
+ key={app.id}
218
+ app={app}
219
+ className="h-full"
220
+ />
221
+ ))}
222
+ </div>
223
+ </CollapsibleContent>
224
+ </section>
225
+ </Collapsible>
226
+ ) : null}
227
+
162
228
  {curatedTemplatesQuery.isError ? (
163
229
  <section className="space-y-3 border-t pt-4">
164
230
  <ActionQueryError
@@ -400,3 +466,17 @@ function EmptyAppsState() {
400
466
  </div>
401
467
  );
402
468
  }
469
+
470
+ function EmptyActiveAppsState() {
471
+ const t = useT();
472
+ return (
473
+ <div className="rounded-lg border border-dashed bg-card px-4 py-10 text-center">
474
+ <p className="text-sm font-medium text-foreground">
475
+ {t("dispatch.pages.noActiveWorkspaceApps")}
476
+ </p>
477
+ <p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
478
+ {t("dispatch.pages.noActiveWorkspaceAppsDescription")}
479
+ </p>
480
+ </div>
481
+ );
482
+ }
@@ -66,6 +66,9 @@ export interface WorkspaceAppSummary {
66
66
  builderUrl?: string | null;
67
67
  branchName?: string | null;
68
68
  createdAt?: string | null;
69
+ createdBy?: string | null;
70
+ owner?: string | null;
71
+ teams?: string[];
69
72
  agentCardUrl?: string | null;
70
73
  agentCardReachable?: boolean;
71
74
  a2aEndpointUrl?: string | null;
@@ -125,6 +128,9 @@ interface PendingWorkspaceApp {
125
128
  contextId: string | null;
126
129
  contextLabel: string | null;
127
130
  audience?: WorkspaceAppAudience;
131
+ createdBy?: string | null;
132
+ owner?: string | null;
133
+ teams?: string[];
128
134
  createdAt: string;
129
135
  updatedAt: string;
130
136
  expiresAt: string | null;
@@ -387,6 +393,12 @@ function applyWorkspaceAppMetadataOverride(
387
393
  ...app,
388
394
  ...(shouldApplyName ? { name } : {}),
389
395
  ...(shouldApplyDescription ? { description } : {}),
396
+ ...(app.status === "pending" && !app.createdBy && override.updatedBy
397
+ ? { createdBy: override.updatedBy }
398
+ : {}),
399
+ ...(app.status === "pending" && !app.owner && override.updatedBy
400
+ ? { owner: override.updatedBy }
401
+ : {}),
390
402
  };
391
403
  }
392
404
 
@@ -448,6 +460,75 @@ function normalizeWorkspaceAppPathList(value: unknown): string[] {
448
460
  return Array.from(new Set(paths));
449
461
  }
450
462
 
463
+ function normalizeWorkspaceAppTeams(value: unknown): string[] {
464
+ const rawTeams = Array.isArray(value)
465
+ ? value
466
+ : typeof value === "string"
467
+ ? value.split(",")
468
+ : [];
469
+ return Array.from(
470
+ new Set(
471
+ rawTeams
472
+ .map((team) => (typeof team === "string" ? team.trim() : ""))
473
+ .filter(Boolean),
474
+ ),
475
+ );
476
+ }
477
+
478
+ function workspaceAppMetadataFromRecord(
479
+ record: Record<string, any>,
480
+ ): Pick<WorkspaceAppSummary, "createdAt" | "createdBy" | "owner" | "teams"> {
481
+ const config = record["agent-native"] ?? record.agentNative;
482
+ const workspaceConfig =
483
+ config && typeof config === "object" && !Array.isArray(config)
484
+ ? (config.workspaceApp ?? config.workspace ?? config)
485
+ : {};
486
+ const metadata =
487
+ record.metadata &&
488
+ typeof record.metadata === "object" &&
489
+ !Array.isArray(record.metadata)
490
+ ? record.metadata
491
+ : {};
492
+ const pick = (...values: unknown[]) =>
493
+ values.map(cleanOptionalText).find((value) => value !== undefined);
494
+
495
+ const teams = normalizeWorkspaceAppTeams(
496
+ record.teams ??
497
+ record.team ??
498
+ metadata.teams ??
499
+ metadata.team ??
500
+ workspaceConfig.teams ??
501
+ workspaceConfig.team,
502
+ );
503
+ return {
504
+ createdAt: pick(
505
+ record.createdAt,
506
+ record.created_at,
507
+ metadata.createdAt,
508
+ workspaceConfig.createdAt,
509
+ ),
510
+ createdBy: pick(
511
+ record.createdBy,
512
+ record.createdByEmail,
513
+ record.created_by,
514
+ metadata.createdBy,
515
+ metadata.createdByEmail,
516
+ workspaceConfig.createdBy,
517
+ workspaceConfig.createdByEmail,
518
+ ),
519
+ owner: pick(
520
+ record.owner,
521
+ record.ownerEmail,
522
+ record.owner_email,
523
+ metadata.owner,
524
+ metadata.ownerEmail,
525
+ workspaceConfig.owner,
526
+ workspaceConfig.ownerEmail,
527
+ ),
528
+ teams,
529
+ };
530
+ }
531
+
451
532
  function workspaceAppAudienceFromPackageJson(
452
533
  pkg: unknown,
453
534
  ): WorkspaceAppAudience | undefined {
@@ -513,6 +594,7 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
513
594
  const id = typeof entry.id === "string" ? entry.id.trim() : "";
514
595
  const pathValue = typeof entry.path === "string" ? entry.path.trim() : "";
515
596
  if (!id || !pathValue.startsWith("/")) return null;
597
+ const metadata = workspaceAppMetadataFromRecord(entry);
516
598
  return {
517
599
  id,
518
600
  name:
@@ -534,6 +616,7 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
534
616
  publicPaths: normalizeWorkspaceAppPathList(entry.publicPaths),
535
617
  protectedPaths: normalizeWorkspaceAppPathList(entry.protectedPaths),
536
618
  status: "ready",
619
+ ...metadata,
537
620
  } satisfies WorkspaceAppSummary;
538
621
  })
539
622
  .filter(
@@ -645,6 +728,9 @@ function parsePendingWorkspaceApps(value: unknown): PendingWorkspaceApp[] {
645
728
  : null,
646
729
  contextId: cleanOptionalString(record.contextId),
647
730
  contextLabel: cleanOptionalString(record.contextLabel),
731
+ createdBy: cleanOptionalString(record.createdBy),
732
+ owner: cleanOptionalString(record.owner ?? record.ownerEmail),
733
+ teams: normalizeWorkspaceAppTeams(record.teams ?? record.team),
648
734
  ...(record.audience === undefined
649
735
  ? {}
650
736
  : { audience: normalizeWorkspaceAppAudience(record.audience) }),
@@ -755,6 +841,9 @@ function pendingAppToSummary(app: PendingWorkspaceApp): WorkspaceAppSummary {
755
841
  builderUrl: app.builderUrl,
756
842
  branchName: app.branchName,
757
843
  createdAt: app.createdAt,
844
+ createdBy: app.createdBy,
845
+ owner: app.owner,
846
+ teams: app.teams,
758
847
  };
759
848
  }
760
849
 
@@ -918,6 +1007,8 @@ async function recordPendingWorkspaceApp(input: {
918
1007
  projectId: input.projectId,
919
1008
  contextId: context?.id ?? null,
920
1009
  contextLabel: context?.label ?? null,
1010
+ createdBy: currentOwnerEmail(),
1011
+ owner: currentOwnerEmail(),
921
1012
  createdAt: existing?.createdAt || now,
922
1013
  updatedAt: now,
923
1014
  expiresAt: pendingWorkspaceAppExpiresAt(existing?.createdAt || now),
@@ -1038,6 +1129,7 @@ function readWorkspaceAppsFromFilesystem(
1038
1129
  const pkg = readJson(path.join(appDir, "package.json"));
1039
1130
  if (!pkg) return null;
1040
1131
  const routeAccess = workspaceAppRouteAccessFromPackageJson(pkg);
1132
+ const metadata = workspaceAppMetadataFromRecord(pkg);
1041
1133
  return {
1042
1134
  id: entry.name,
1043
1135
  name: pkg.displayName || titleCase(entry.name),
@@ -1051,6 +1143,7 @@ function readWorkspaceAppsFromFilesystem(
1051
1143
  publicPaths: routeAccess.publicPaths,
1052
1144
  protectedPaths: routeAccess.protectedPaths,
1053
1145
  status: "ready",
1146
+ ...metadata,
1054
1147
  } satisfies WorkspaceAppSummary;
1055
1148
  })
1056
1149
  .filter((app): app is WorkspaceAppSummary => !!app)