@agent-native/dispatch 0.19.2 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/actions/provider-api-register.d.ts +5 -5
  2. package/dist/actions/start-workspace-app-creation.js +1 -1
  3. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  4. package/dist/components/admin-navigation.d.ts +2 -1
  5. package/dist/components/admin-navigation.d.ts.map +1 -1
  6. package/dist/components/admin-navigation.js +10 -7
  7. package/dist/components/admin-navigation.js.map +1 -1
  8. package/dist/components/app-icon.d.ts +9 -0
  9. package/dist/components/app-icon.d.ts.map +1 -0
  10. package/dist/components/app-icon.js +85 -0
  11. package/dist/components/app-icon.js.map +1 -0
  12. package/dist/components/app-list-row.d.ts +12 -0
  13. package/dist/components/app-list-row.d.ts.map +1 -0
  14. package/dist/components/app-list-row.js +11 -0
  15. package/dist/components/app-list-row.js.map +1 -0
  16. package/dist/components/app-open-actions.d.ts +17 -0
  17. package/dist/components/app-open-actions.d.ts.map +1 -0
  18. package/dist/components/app-open-actions.js +20 -0
  19. package/dist/components/app-open-actions.js.map +1 -0
  20. package/dist/components/connected-app-card.d.ts +2 -1
  21. package/dist/components/connected-app-card.d.ts.map +1 -1
  22. package/dist/components/connected-app-card.js +8 -8
  23. package/dist/components/connected-app-card.js.map +1 -1
  24. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  25. package/dist/components/dispatch-control-plane.js +21 -14
  26. package/dist/components/dispatch-control-plane.js.map +1 -1
  27. package/dist/components/layout/Layout.d.ts.map +1 -1
  28. package/dist/components/layout/Layout.js +40 -19
  29. package/dist/components/layout/Layout.js.map +1 -1
  30. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
  31. package/dist/components/layout/workspace-apps-rail.js +10 -10
  32. package/dist/components/layout/workspace-apps-rail.js.map +1 -1
  33. package/dist/components/other-apps-section.d.ts +31 -0
  34. package/dist/components/other-apps-section.d.ts.map +1 -0
  35. package/dist/components/other-apps-section.js +59 -0
  36. package/dist/components/other-apps-section.js.map +1 -0
  37. package/dist/components/workspace-app-card.d.ts.map +1 -1
  38. package/dist/components/workspace-app-card.js +38 -28
  39. package/dist/components/workspace-app-card.js.map +1 -1
  40. package/dist/components/workspace-template-card.d.ts +8 -2
  41. package/dist/components/workspace-template-card.d.ts.map +1 -1
  42. package/dist/components/workspace-template-card.js +17 -13
  43. package/dist/components/workspace-template-card.js.map +1 -1
  44. package/dist/lib/other-apps.d.ts.map +1 -1
  45. package/dist/lib/other-apps.js +5 -1
  46. package/dist/lib/other-apps.js.map +1 -1
  47. package/dist/lib/workspace-apps.d.ts +1 -0
  48. package/dist/lib/workspace-apps.d.ts.map +1 -1
  49. package/dist/lib/workspace-apps.js +3 -0
  50. package/dist/lib/workspace-apps.js.map +1 -1
  51. package/dist/routes/index.d.ts.map +1 -1
  52. package/dist/routes/index.js +0 -2
  53. package/dist/routes/index.js.map +1 -1
  54. package/dist/routes/pages/admin._index.d.ts.map +1 -1
  55. package/dist/routes/pages/admin._index.js +32 -7
  56. package/dist/routes/pages/admin._index.js.map +1 -1
  57. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  58. package/dist/routes/pages/apps.$appId.js +62 -13
  59. package/dist/routes/pages/apps.$appId.js.map +1 -1
  60. package/dist/routes/pages/apps.d.ts +1 -1
  61. package/dist/routes/pages/apps.d.ts.map +1 -1
  62. package/dist/routes/pages/apps.js +21 -30
  63. package/dist/routes/pages/apps.js.map +1 -1
  64. package/dist/server/plugins/agent-chat.js +6 -1
  65. package/dist/server/plugins/agent-chat.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/actions/start-workspace-app-creation.ts +1 -1
  68. package/src/components/admin-navigation.tsx +21 -16
  69. package/src/components/app-icon.tsx +149 -0
  70. package/src/components/app-list-row.tsx +40 -0
  71. package/src/components/app-open-actions.tsx +111 -0
  72. package/src/components/connected-app-card.tsx +30 -34
  73. package/src/components/dispatch-control-plane.spec.tsx +28 -1
  74. package/src/components/dispatch-control-plane.tsx +122 -48
  75. package/src/components/layout/Layout.spec.tsx +4 -4
  76. package/src/components/layout/Layout.tsx +86 -55
  77. package/src/components/layout/workspace-apps-rail.tsx +20 -21
  78. package/src/components/other-apps-section.tsx +195 -0
  79. package/src/components/workspace-app-card.spec.tsx +58 -32
  80. package/src/components/workspace-app-card.tsx +222 -158
  81. package/src/components/workspace-template-card.spec.tsx +45 -7
  82. package/src/components/workspace-template-card.tsx +74 -81
  83. package/src/lib/other-apps.spec.ts +31 -0
  84. package/src/lib/other-apps.ts +9 -1
  85. package/src/lib/workspace-apps.ts +4 -0
  86. package/src/routes/index.spec.ts +2 -1
  87. package/src/routes/index.ts +0 -2
  88. package/src/routes/pages/admin._index.tsx +85 -17
  89. package/src/routes/pages/apps.$appId.tsx +144 -57
  90. package/src/routes/pages/apps.tsx +76 -147
  91. package/src/server/plugins/agent-chat.spec.ts +5 -0
  92. package/src/server/plugins/agent-chat.ts +6 -1
@@ -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
  });
@@ -8,15 +8,16 @@ import {
8
8
  IconChevronDown,
9
9
  IconChevronRight,
10
10
  IconClockHour4,
11
- IconDots,
12
11
  IconEdit,
13
12
  IconEye,
14
13
  IconEyeOff,
15
14
  IconFileText,
16
- IconWorld,
15
+ IconKey,
16
+ IconSettings,
17
17
  IconTrash,
18
18
  IconUser,
19
19
  IconUsersGroup,
20
+ IconWorld,
20
21
  } from "@tabler/icons-react";
21
22
  import { useEffect, useState, type FormEvent } from "react";
22
23
  import { toast } from "sonner";
@@ -28,7 +29,10 @@ import {
28
29
  type WorkspaceAppSummary,
29
30
  } from "../lib/workspace-apps";
30
31
  import { ActionQueryError } from "./action-query-error";
31
- import { AppKeysPopover } from "./app-keys-popover";
32
+ import { AppIcon } from "./app-icon";
33
+ import { AppKeysPanel } from "./app-keys-popover";
34
+ import { AppListRow } from "./app-list-row";
35
+ import { AppOpenActions } from "./app-open-actions";
32
36
  import { Badge } from "./ui/badge";
33
37
  import { Button } from "./ui/button";
34
38
  import {
@@ -38,7 +42,6 @@ import {
38
42
  DialogFooter,
39
43
  DialogHeader,
40
44
  DialogTitle,
41
- DialogTrigger,
42
45
  } from "./ui/dialog";
43
46
  import {
44
47
  DropdownMenu,
@@ -67,12 +70,13 @@ export function WorkspaceAppCard({
67
70
  const t = useT();
68
71
  const { formatDate } = useFormatters();
69
72
  const href = workspaceAppHref(app);
70
- const openInNewTab = isPendingBuilderHref(app);
71
73
  const isPending = app.status === "pending";
72
74
  const pendingLabel = app.statusLabel || "Builder branch";
73
75
  const isArchived = !!app.archived;
74
76
  const audience = app.audience ?? "internal";
75
77
  const [editOpen, setEditOpen] = useState(false);
78
+ const [keysOpen, setKeysOpen] = useState(false);
79
+ const [resourcesOpen, setResourcesOpen] = useState(false);
76
80
  const [draftName, setDraftName] = useState(app.name);
77
81
  const [draftDescription, setDraftDescription] = useState(
78
82
  app.description || "",
@@ -132,145 +136,71 @@ export function WorkspaceAppCard({
132
136
  };
133
137
 
134
138
  return (
135
- <div
136
- aria-disabled={!href}
137
- className={cn(
138
- "group relative rounded-xl bg-card/40 p-4 transition-[background-color] hover:bg-accent/15 focus-within:bg-accent/15 aria-disabled:opacity-60",
139
- isArchived && "opacity-70",
140
- className,
141
- )}
142
- >
143
- {href ? (
144
- <a
145
- href={href}
146
- target={openInNewTab ? "_blank" : undefined}
147
- rel={openInNewTab ? "noreferrer" : undefined}
148
- aria-label={`Open ${app.name}`}
149
- className="absolute inset-0 z-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
150
- />
151
- ) : null}
152
-
153
- <div className="pointer-events-none relative z-10 flex h-full min-w-0 items-start justify-between gap-3">
139
+ <>
140
+ <AppListRow
141
+ className={cn(
142
+ !href && "opacity-60",
143
+ isArchived && "opacity-70",
144
+ className,
145
+ )}
146
+ >
147
+ <AppIcon id={app.id} name={app.name} size="sm" />
154
148
  <div className="min-w-0 flex-1">
155
- <div className="flex min-w-0 flex-wrap items-center gap-1.5">
156
- <h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
157
- {app.name}
158
- </h3>
149
+ <h3 className="truncate text-sm font-semibold text-foreground">
150
+ {app.name}
151
+ </h3>
152
+ {app.description || isPending ? (
153
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
154
+ {app.description || pendingLabel}
155
+ </p>
156
+ ) : null}
157
+ <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
159
158
  {isPending ? (
160
- <Badge
161
- variant="outline"
162
- className="shrink-0 gap-1 border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"
163
- >
159
+ <span className="inline-flex min-w-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5">
164
160
  <IconClockHour4 size={12} />
165
- {pendingLabel}
166
- </Badge>
161
+ <span className="truncate">{pendingLabel}</span>
162
+ </span>
167
163
  ) : null}
168
164
  {isArchived ? (
169
- <Badge variant="outline" className="shrink-0 gap-1">
165
+ <span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5">
170
166
  <IconEyeOff size={12} />
171
167
  Hidden
172
- </Badge>
168
+ </span>
173
169
  ) : null}
174
170
  {audience === "public" ? (
175
- <Badge variant="outline" className="shrink-0 gap-1">
171
+ <span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5">
176
172
  <IconWorld size={12} />
177
173
  Public
178
- </Badge>
174
+ </span>
175
+ ) : null}
176
+ {isPending && app.branchName ? (
177
+ <span className="min-w-0 truncate">{app.branchName}</span>
179
178
  ) : null}
180
179
  </div>
181
- <p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
182
- {app.path}
183
- </p>
184
- {isPending && app.branchName ? (
185
- <p className="mt-1 truncate text-xs text-muted-foreground">
186
- Builder branch: {app.branchName}
187
- </p>
188
- ) : null}
189
- {app.description ? (
190
- <p className="mt-2 min-h-10 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
191
- {app.description}
192
- </p>
193
- ) : null}
194
180
  </div>
195
- <div className="flex shrink-0 items-center gap-1">
196
- {!isPending && !isArchived ? (
197
- <div className="pointer-events-auto">
198
- <AppResourcesDialog app={app} />
199
- </div>
200
- ) : null}
201
- {!isPending && !isArchived ? (
202
- <div className="pointer-events-auto">
203
- <AppKeysPopover appId={app.id} appName={app.name} />
204
- </div>
205
- ) : null}
206
- <div className="pointer-events-auto">
207
- <DropdownMenu>
208
- <Tooltip>
209
- <TooltipTrigger asChild>
210
- <DropdownMenuTrigger asChild>
211
- <button
212
- type="button"
213
- aria-label={`More actions for ${app.name}`}
214
- className={cn(
215
- APP_CARD_ACTION_CLASS,
216
- "inline-flex cursor-pointer items-center justify-center",
217
- )}
218
- onClick={(e) => e.stopPropagation()}
219
- >
220
- <IconDots size={15} />
221
- </button>
222
- </DropdownMenuTrigger>
223
- </TooltipTrigger>
224
- <TooltipContent>More actions</TooltipContent>
225
- </Tooltip>
226
- <DropdownMenuContent align="end" className="w-72">
227
- <DropdownMenuItem
228
- onSelect={(event) => {
229
- event.preventDefault();
230
- setEditOpen(true);
231
- }}
232
- >
233
- <IconEdit size={14} className="mr-2" />
234
- Edit details
235
- </DropdownMenuItem>
236
- {isPending ? (
237
- <DropdownMenuItem
238
- onSelect={handleRemovePending}
239
- className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
240
- >
241
- <IconTrash size={14} className="mr-2" />
242
- Remove from list
243
- </DropdownMenuItem>
244
- ) : isArchived ? (
245
- <DropdownMenuItem onSelect={handleUnarchive}>
246
- <IconEye size={14} className="mr-2" />
247
- Restore to list
248
- </DropdownMenuItem>
249
- ) : (
250
- <DropdownMenuItem onSelect={handleArchive}>
251
- <IconEyeOff size={14} className="mr-2" />
252
- Hide from list
253
- </DropdownMenuItem>
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>
269
- </DropdownMenuContent>
270
- </DropdownMenu>
271
- </div>
181
+ <div className="flex shrink-0 items-center gap-2">
182
+ <WorkspaceAppOpenActions app={app} href={href} />
183
+ <WorkspaceAppSettings
184
+ app={app}
185
+ formatDate={formatDate}
186
+ isArchived={isArchived}
187
+ isPending={isPending}
188
+ onEdit={() => setEditOpen(true)}
189
+ onKeys={() => setKeysOpen(true)}
190
+ onResources={() => setResourcesOpen(true)}
191
+ onArchive={handleArchive}
192
+ onUnarchive={handleUnarchive}
193
+ onRemovePending={handleRemovePending}
194
+ labels={{
195
+ created: t("agents.dashboardMetadataCreated"),
196
+ createdBy: t("agents.dashboardMetadataCreatedBy"),
197
+ owner: t("dispatch.pages.appMetadataOwner"),
198
+ teams: t("dispatch.pages.appMetadataTeams"),
199
+ notTracked: t("agents.notTracked"),
200
+ }}
201
+ />
272
202
  </div>
273
- </div>
203
+ </AppListRow>
274
204
  <Dialog open={editOpen} onOpenChange={setEditOpen}>
275
205
  <DialogContent>
276
206
  <DialogHeader>
@@ -311,7 +241,157 @@ export function WorkspaceAppCard({
311
241
  </form>
312
242
  </DialogContent>
313
243
  </Dialog>
314
- </div>
244
+ <Dialog open={keysOpen} onOpenChange={setKeysOpen}>
245
+ <DialogContent className="max-w-md">
246
+ <DialogHeader>
247
+ <DialogTitle>Manage keys for {app.name}</DialogTitle>
248
+ <DialogDescription>
249
+ Choose which workspace credentials this app can use.
250
+ </DialogDescription>
251
+ </DialogHeader>
252
+ <AppKeysPanel appId={app.id} appName={app.name} />
253
+ <DialogFooter>
254
+ <Button type="button" onClick={() => setKeysOpen(false)}>
255
+ Done
256
+ </Button>
257
+ </DialogFooter>
258
+ </DialogContent>
259
+ </Dialog>
260
+ <AppResourcesDialog
261
+ app={app}
262
+ open={resourcesOpen}
263
+ onOpenChange={setResourcesOpen}
264
+ />
265
+ </>
266
+ );
267
+ }
268
+
269
+ function WorkspaceAppOpenActions({
270
+ app,
271
+ href,
272
+ }: {
273
+ app: WorkspaceAppSummary;
274
+ href: string | null;
275
+ }) {
276
+ const openInNewTab = isPendingBuilderHref(app);
277
+
278
+ if (!href) {
279
+ return (
280
+ <Button size="sm" variant="outline" disabled>
281
+ Open app
282
+ </Button>
283
+ );
284
+ }
285
+
286
+ return (
287
+ <AppOpenActions
288
+ name={app.name}
289
+ href={href}
290
+ target={openInNewTab ? "_blank" : undefined}
291
+ rel={openInNewTab ? "noreferrer" : undefined}
292
+ showInlineOption
293
+ showNewTabOption
294
+ />
295
+ );
296
+ }
297
+
298
+ type WorkspaceAppMetadataLabels = {
299
+ created: string;
300
+ createdBy: string;
301
+ owner: string;
302
+ teams: string;
303
+ notTracked: string;
304
+ };
305
+
306
+ function WorkspaceAppSettings({
307
+ app,
308
+ formatDate,
309
+ isArchived,
310
+ isPending,
311
+ onEdit,
312
+ onKeys,
313
+ onResources,
314
+ onArchive,
315
+ onUnarchive,
316
+ onRemovePending,
317
+ labels,
318
+ }: {
319
+ app: WorkspaceAppSummary;
320
+ formatDate: ReturnType<typeof useFormatters>["formatDate"];
321
+ isArchived: boolean;
322
+ isPending: boolean;
323
+ onEdit: () => void;
324
+ onKeys: () => void;
325
+ onResources: () => void;
326
+ onArchive: () => void;
327
+ onUnarchive: () => void;
328
+ onRemovePending: () => void;
329
+ labels: WorkspaceAppMetadataLabels;
330
+ }) {
331
+ return (
332
+ <DropdownMenu>
333
+ <Tooltip>
334
+ <TooltipTrigger asChild>
335
+ <DropdownMenuTrigger asChild>
336
+ <Button
337
+ type="button"
338
+ variant="ghost"
339
+ size="sm"
340
+ aria-label={`Settings for ${app.name}`}
341
+ className={APP_CARD_ACTION_CLASS}
342
+ >
343
+ <IconSettings size={15} />
344
+ </Button>
345
+ </DropdownMenuTrigger>
346
+ </TooltipTrigger>
347
+ <TooltipContent>App settings</TooltipContent>
348
+ </Tooltip>
349
+ <DropdownMenuContent align="end" className="w-72">
350
+ <DropdownMenuItem onSelect={onEdit}>
351
+ <IconEdit size={14} className="mr-2" />
352
+ Edit details
353
+ </DropdownMenuItem>
354
+ {!isPending && !isArchived ? (
355
+ <>
356
+ <DropdownMenuItem onSelect={onKeys}>
357
+ <IconKey size={14} className="mr-2" />
358
+ Manage keys
359
+ </DropdownMenuItem>
360
+ <DropdownMenuItem onSelect={onResources}>
361
+ <IconFileText size={14} className="mr-2" />
362
+ Agent resources
363
+ </DropdownMenuItem>
364
+ </>
365
+ ) : null}
366
+ {isPending ? (
367
+ <DropdownMenuItem
368
+ onSelect={onRemovePending}
369
+ className="text-destructive focus:text-destructive"
370
+ >
371
+ <IconTrash size={14} className="mr-2" />
372
+ Remove from list
373
+ </DropdownMenuItem>
374
+ ) : isArchived ? (
375
+ <DropdownMenuItem onSelect={onUnarchive}>
376
+ <IconEye size={14} className="mr-2" />
377
+ Restore to list
378
+ </DropdownMenuItem>
379
+ ) : (
380
+ <DropdownMenuItem onSelect={onArchive}>
381
+ <IconEyeOff size={14} className="mr-2" />
382
+ Hide from list
383
+ </DropdownMenuItem>
384
+ )}
385
+ <DropdownMenuSeparator />
386
+ <DropdownMenuLabel className="font-normal">
387
+ <WorkspaceAppMetadata
388
+ app={app}
389
+ formatDate={formatDate}
390
+ labels={labels}
391
+ />
392
+ </DropdownMenuLabel>
393
+ </DropdownMenuContent>
394
+ </DropdownMenu>
315
395
  );
316
396
  }
317
397
 
@@ -322,13 +402,7 @@ function WorkspaceAppMetadata({
322
402
  }: {
323
403
  app: WorkspaceAppSummary;
324
404
  formatDate: ReturnType<typeof useFormatters>["formatDate"];
325
- labels: {
326
- created: string;
327
- createdBy: string;
328
- owner: string;
329
- teams: string;
330
- notTracked: string;
331
- };
405
+ labels: WorkspaceAppMetadataLabels;
332
406
  }) {
333
407
  function formatMetadataDate(value: string | null | undefined): string {
334
408
  if (!value) return labels.notTracked;
@@ -389,8 +463,15 @@ function WorkspaceAppMetadataRow({
389
463
  );
390
464
  }
391
465
 
392
- function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
393
- const [open, setOpen] = useState(false);
466
+ function AppResourcesDialog({
467
+ app,
468
+ open,
469
+ onOpenChange,
470
+ }: {
471
+ app: WorkspaceAppSummary;
472
+ open: boolean;
473
+ onOpenChange: (open: boolean) => void;
474
+ }) {
394
475
  const [inspectedResourceId, setInspectedResourceId] = useState<string | null>(
395
476
  null,
396
477
  );
@@ -408,27 +489,10 @@ function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
408
489
  <Dialog
409
490
  open={open}
410
491
  onOpenChange={(nextOpen) => {
411
- setOpen(nextOpen);
492
+ onOpenChange(nextOpen);
412
493
  if (!nextOpen) setInspectedResourceId(null);
413
494
  }}
414
495
  >
415
- <Tooltip>
416
- <TooltipTrigger asChild>
417
- <DialogTrigger asChild>
418
- <Button
419
- type="button"
420
- variant="ghost"
421
- size="sm"
422
- aria-label={`View agent resources for ${app.name}`}
423
- className={APP_CARD_ACTION_CLASS}
424
- onClick={(e) => e.stopPropagation()}
425
- >
426
- <IconFileText size={14} />
427
- </Button>
428
- </DialogTrigger>
429
- </TooltipTrigger>
430
- <TooltipContent>View agent resources</TooltipContent>
431
- </Tooltip>
432
496
  <DialogContent className="max-w-2xl">
433
497
  <DialogHeader>
434
498
  <DialogTitle>{app.name} agent resources</DialogTitle>
@@ -546,7 +610,7 @@ function AppResourcesDialog({ app }: { app: WorkspaceAppSummary }) {
546
610
  )}
547
611
  </div>
548
612
  <DialogFooter>
549
- <Button type="button" onClick={() => setOpen(false)}>
613
+ <Button type="button" onClick={() => onOpenChange(false)}>
550
614
  Done
551
615
  </Button>
552
616
  </DialogFooter>