@agent-native/dispatch 0.25.0 → 0.26.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 (29) hide show
  1. package/dist/actions/provider-api-register.d.ts +11 -11
  2. package/dist/components/create-app-popover.d.ts +7 -2
  3. package/dist/components/create-app-popover.d.ts.map +1 -1
  4. package/dist/components/create-app-popover.js +4 -3
  5. package/dist/components/create-app-popover.js.map +1 -1
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/components/index.d.ts.map +1 -1
  8. package/dist/components/index.js +1 -0
  9. package/dist/components/index.js.map +1 -1
  10. package/dist/components/layout/Layout.d.ts.map +1 -1
  11. package/dist/components/layout/Layout.js +42 -2
  12. package/dist/components/layout/Layout.js.map +1 -1
  13. package/dist/components/simple-agents-panel.d.ts +4 -1
  14. package/dist/components/simple-agents-panel.d.ts.map +1 -1
  15. package/dist/components/simple-agents-panel.js +46 -13
  16. package/dist/components/simple-agents-panel.js.map +1 -1
  17. package/dist/components/workspace-app-host.d.ts +3 -1
  18. package/dist/components/workspace-app-host.d.ts.map +1 -1
  19. package/dist/components/workspace-app-host.js +11 -2
  20. package/dist/components/workspace-app-host.js.map +1 -1
  21. package/package.json +3 -3
  22. package/src/components/create-app-popover.spec.tsx +9 -1
  23. package/src/components/create-app-popover.tsx +9 -1
  24. package/src/components/index.ts +1 -0
  25. package/src/components/layout/Layout.app-chat.spec.ts +22 -0
  26. package/src/components/layout/Layout.tsx +72 -3
  27. package/src/components/simple-agents-panel.spec.tsx +81 -1
  28. package/src/components/simple-agents-panel.tsx +156 -71
  29. package/src/components/workspace-app-host.tsx +30 -1
@@ -60,6 +60,10 @@ interface CreateAppPopoverProps {
60
60
  * Override the popover alignment. Defaults to "center" with a 10px offset.
61
61
  */
62
62
  align?: "start" | "center" | "end";
63
+ /**
64
+ * Called after the server accepts a Builder app creation request.
65
+ */
66
+ onCreated?: () => void;
63
67
  }
64
68
 
65
69
  async function fetchJson(url: string, init?: RequestInit): Promise<any> {
@@ -107,9 +111,11 @@ function isErrorFailureReason(reason: string | null): boolean {
107
111
  */
108
112
  export function CreateAppFlow({
109
113
  onClose,
114
+ onCreated,
110
115
  className = "",
111
116
  }: {
112
117
  onClose?: () => void;
118
+ onCreated?: () => void;
113
119
  className?: string;
114
120
  }) {
115
121
  const [step, setStep] = useState<"prompt" | "access">("prompt");
@@ -269,6 +275,7 @@ export function CreateAppFlow({
269
275
  },
270
276
  );
271
277
  if (result?.mode === "builder") {
278
+ onCreated?.();
272
279
  setBranchUrl(result?.url || null);
273
280
  setStatusMessage("Builder branch created.");
274
281
  } else if (result?.mode === "local-agent") {
@@ -634,6 +641,7 @@ export function CreateAppFlow({
634
641
  export function CreateAppPopover({
635
642
  trigger,
636
643
  align = "center",
644
+ onCreated,
637
645
  }: CreateAppPopoverProps) {
638
646
  const [open, setOpen] = useState(false);
639
647
  return (
@@ -656,7 +664,7 @@ export function CreateAppPopover({
656
664
  sideOffset={10}
657
665
  className="w-[calc(100vw-2rem)] rounded-xl p-3 shadow-xl sm:w-[460px]"
658
666
  >
659
- <CreateAppFlow onClose={() => setOpen(false)} />
667
+ <CreateAppFlow onClose={() => setOpen(false)} onCreated={onCreated} />
660
668
  </PopoverContent>
661
669
  </Popover>
662
670
  );
@@ -18,3 +18,4 @@ export { CreateAppPopover, CreateAppFlow } from "./create-app-popover.js";
18
18
  export { AppKeysPopover } from "./app-keys-popover.js";
19
19
  export { ActionQueryError } from "./action-query-error.js";
20
20
  export { SimpleAgentsPanel } from "./simple-agents-panel.js";
21
+ export { WorkspaceAppCard } from "./workspace-app-card.js";
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ describe("Dispatch workspace app chat rail", () => {
6
+ it("owns the iframe chat toggle and persists app-specific threads", () => {
7
+ const source = readFileSync(
8
+ new URL("./Layout.tsx", import.meta.url),
9
+ "utf8",
10
+ );
11
+
12
+ expect(source).toContain('openStorageKey="dispatch-app-chat"');
13
+ expect(source).toContain(
14
+ "storageKey={`dispatch-app-chat:${workspaceAppId}`}",
15
+ );
16
+ expect(source).toContain("data-dispatch-workspace-app-frame");
17
+ expect(source).toContain("chatSidebar");
18
+ expect(source).toContain(
19
+ 'window.dispatchEvent(new Event("agent-panel:toggle"))',
20
+ );
21
+ });
22
+ });
@@ -1941,6 +1941,39 @@ export function Layout({
1941
1941
  setSidebarCollapsed(true);
1942
1942
  }, [isWorkspaceAppRoute, localPathname]);
1943
1943
 
1944
+ useEffect(() => {
1945
+ if (!workspaceAppRouteActive && !chatFirstAppTakesMain) return;
1946
+
1947
+ const handleWorkspaceAppMessage = (event: MessageEvent) => {
1948
+ if (event.data?.type !== "agentNative.toggleSidebar") return;
1949
+ const frame = [
1950
+ ...document.querySelectorAll<HTMLIFrameElement>(
1951
+ "[data-dispatch-workspace-app-frame]",
1952
+ ),
1953
+ ].find(
1954
+ (candidate) =>
1955
+ candidate
1956
+ .closest("[data-chat-first-surface-content]")
1957
+ ?.getAttribute("aria-hidden") !== "true",
1958
+ );
1959
+ if (!(frame instanceof HTMLIFrameElement)) return;
1960
+ if (event.source !== frame.contentWindow) return;
1961
+
1962
+ const open = event.data.data?.open;
1963
+ if (open === true) {
1964
+ window.dispatchEvent(new Event("agent-panel:open"));
1965
+ } else if (open === false) {
1966
+ window.dispatchEvent(new Event("agent-panel:close"));
1967
+ } else {
1968
+ window.dispatchEvent(new Event("agent-panel:toggle"));
1969
+ }
1970
+ };
1971
+
1972
+ window.addEventListener("message", handleWorkspaceAppMessage);
1973
+ return () =>
1974
+ window.removeEventListener("message", handleWorkspaceAppMessage);
1975
+ }, [chatFirstAppTakesMain, workspaceAppRouteActive]);
1976
+
1944
1977
  useEffect(() => {
1945
1978
  if (typeof window === "undefined" || isWorkspaceAppHostRoute) return;
1946
1979
  try {
@@ -2077,6 +2110,7 @@ export function Layout({
2077
2110
  url: registration.url,
2078
2111
  }}
2079
2112
  embedPath={embedPath}
2113
+ chatSidebar
2080
2114
  copy={chatFirstCopy}
2081
2115
  />
2082
2116
  );
@@ -2300,6 +2334,43 @@ export function Layout({
2300
2334
  renderTab={renderChatFirstSurfaceTab}
2301
2335
  />
2302
2336
  ) : null;
2337
+ const workspaceAppChatName =
2338
+ (workspaceAppId
2339
+ ? chatFirstAppRegistrations.find(
2340
+ (app) => app.id.toLowerCase() === workspaceAppId.toLowerCase(),
2341
+ )?.name
2342
+ : null) ??
2343
+ workspaceAppId ??
2344
+ "Workspace app";
2345
+ const workspaceAppContent =
2346
+ workspaceAppRouteActive && workspaceAppId ? (
2347
+ <AgentSidebar
2348
+ position="left"
2349
+ defaultOpen
2350
+ openStorageKey="dispatch-app-chat"
2351
+ storageKey={`dispatch-app-chat:${workspaceAppId}`}
2352
+ scope={{
2353
+ type: "workspace-app",
2354
+ id: workspaceAppId,
2355
+ label: workspaceAppChatName,
2356
+ contextKey: `workspace-app:${workspaceAppId}`,
2357
+ }}
2358
+ agentChatSurface="app"
2359
+ showTabBar
2360
+ suppressInlineOpenApp
2361
+ dynamicSuggestions={false}
2362
+ suggestions={[]}
2363
+ emptyStateText={`Ask about ${workspaceAppChatName}`}
2364
+ agentPageHref={agentPageHref}
2365
+ onFullscreenRequest={openAskAgentFullscreen}
2366
+ >
2367
+ <WorkspaceAppKeepAlive activeAppId={workspaceAppId} />
2368
+ </AgentSidebar>
2369
+ ) : (
2370
+ <WorkspaceAppKeepAlive
2371
+ activeAppId={workspaceAppRouteActive ? workspaceAppId : null}
2372
+ />
2373
+ );
2303
2374
  const content = isChatRoute ? (
2304
2375
  <div
2305
2376
  className={cn(
@@ -2438,9 +2509,7 @@ export function Layout({
2438
2509
 
2439
2510
  <div className="relative min-w-0 flex-1 overflow-hidden">
2440
2511
  {content}
2441
- <WorkspaceAppKeepAlive
2442
- activeAppId={workspaceAppRouteActive ? workspaceAppId : null}
2443
- />
2512
+ {workspaceAppContent}
2444
2513
  </div>
2445
2514
  </div>
2446
2515
  </HeaderActionsProvider>
@@ -1,10 +1,46 @@
1
- import { describe, expect, it } from "vitest";
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
5
 
3
6
  import {
4
7
  handleAgentPackMutationSuccess,
5
8
  isPendingWorkspaceResourceApproval,
9
+ SimpleAgentsPanel,
6
10
  } from "./simple-agents-panel";
7
11
 
12
+ const queryState = vi.hoisted(() => ({
13
+ data: [],
14
+ isError: false,
15
+ isLoading: false,
16
+ error: null,
17
+ refetch: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("@agent-native/core/client/agent-chat", () => ({
21
+ navigateWithAgentChatViewTransition: vi.fn(),
22
+ sendToAgentChat: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("@agent-native/core/client/hooks", () => ({
26
+ useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
27
+ useActionQuery: () => queryState,
28
+ }));
29
+
30
+ vi.mock("@agent-native/core/resources/metadata", () => ({
31
+ parseCustomAgentProfile: vi.fn(),
32
+ }));
33
+
34
+ vi.mock("react-router", () => ({ useNavigate: () => vi.fn() }));
35
+
36
+ vi.mock("sonner", () => ({
37
+ toast: {
38
+ error: vi.fn(),
39
+ info: vi.fn(),
40
+ success: vi.fn(),
41
+ },
42
+ }));
43
+
8
44
  describe("agent pack resource mutations", () => {
9
45
  it("recognizes pending workspace-resource approvals", () => {
10
46
  expect(
@@ -71,3 +107,47 @@ describe("agent pack resource mutations", () => {
71
107
  expect(notifications).toEqual(["Pack file added", "refreshed"]);
72
108
  });
73
109
  });
110
+
111
+ describe("SimpleAgentsPanel", () => {
112
+ let container: HTMLDivElement;
113
+ let root: Root;
114
+
115
+ beforeEach(() => {
116
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
117
+ queryState.data = [];
118
+ queryState.isError = false;
119
+ queryState.isLoading = false;
120
+ queryState.error = null;
121
+ queryState.refetch.mockReset();
122
+ container = document.createElement("div");
123
+ document.body.appendChild(container);
124
+ root = createRoot(container);
125
+ });
126
+
127
+ afterEach(() => {
128
+ act(() => root.unmount());
129
+ container.remove();
130
+ document.body
131
+ .querySelectorAll("[data-radix-portal]")
132
+ .forEach((portal) => portal.remove());
133
+ vi.unstubAllGlobals();
134
+ });
135
+
136
+ it("keeps import and connect available when the workspace has no agents", async () => {
137
+ await act(async () => {
138
+ root.render(<SimpleAgentsPanel />);
139
+ });
140
+
141
+ const importButton = Array.from(container.querySelectorAll("button")).find(
142
+ (button) => button.textContent?.includes("Import or connect"),
143
+ );
144
+ expect(importButton).not.toBeUndefined();
145
+
146
+ await act(async () => {
147
+ importButton?.click();
148
+ });
149
+
150
+ expect(document.body.textContent).toContain("Import an agent");
151
+ expect(document.body.textContent).toContain("Connect endpoint");
152
+ });
153
+ });
@@ -10,6 +10,7 @@ import { parseCustomAgentProfile } from "@agent-native/core/resources/metadata";
10
10
  import {
11
11
  IconAdjustmentsHorizontal,
12
12
  IconChevronDown,
13
+ IconDotsVertical,
13
14
  IconEdit,
14
15
  IconFileImport,
15
16
  IconFolder,
@@ -19,7 +20,6 @@ import {
19
20
  IconPlus,
20
21
  IconTrash,
21
22
  IconUpload,
22
- IconUser,
23
23
  } from "@tabler/icons-react";
24
24
  import {
25
25
  useEffect,
@@ -36,6 +36,7 @@ import {
36
36
  slugifyAgentName,
37
37
  } from "../lib/simple-agent-profile.js";
38
38
  import { ActionQueryError } from "./action-query-error";
39
+ import { AppIcon } from "./app-icon";
39
40
  import {
40
41
  AlertDialog,
41
42
  AlertDialogAction,
@@ -63,6 +64,13 @@ import {
63
64
  DialogTitle,
64
65
  DialogTrigger,
65
66
  } from "./ui/dialog";
67
+ import {
68
+ DropdownMenu,
69
+ DropdownMenuContent,
70
+ DropdownMenuItem,
71
+ DropdownMenuSeparator,
72
+ DropdownMenuTrigger,
73
+ } from "./ui/dropdown-menu";
66
74
  import { Input } from "./ui/input";
67
75
  import { Label } from "./ui/label";
68
76
  import {
@@ -101,6 +109,25 @@ interface AgentPackFileInput {
101
109
  content: string;
102
110
  }
103
111
 
112
+ const AGENT_ICON_KEYS = [
113
+ "brain",
114
+ "users",
115
+ "filetext",
116
+ "chartbar",
117
+ "route",
118
+ "listcheck",
119
+ "code",
120
+ "messagecircle",
121
+ ] as const;
122
+
123
+ function agentIconKey(resource: Pick<WorkspaceAgentResource, "id" | "name">) {
124
+ let hash = 0;
125
+ for (const character of `${resource.id}:${resource.name}`) {
126
+ hash = (hash * 31 + character.charCodeAt(0)) | 0;
127
+ }
128
+ return AGENT_ICON_KEYS[Math.abs(hash) % AGENT_ICON_KEYS.length];
129
+ }
130
+
104
131
  export function isPendingWorkspaceResourceApproval(result: unknown) {
105
132
  if (!result || typeof result !== "object") return false;
106
133
  const mutation = result as { status?: unknown; changeType?: unknown };
@@ -133,6 +160,8 @@ interface AgentEditorProps {
133
160
  resource?: WorkspaceAgentResource;
134
161
  trigger?: ReactNode;
135
162
  onSaved?: () => void;
163
+ open?: boolean;
164
+ onOpenChange?: (open: boolean) => void;
136
165
  }
137
166
 
138
167
  function profileFields(resource?: WorkspaceAgentResource) {
@@ -158,9 +187,17 @@ function profileFields(resource?: WorkspaceAgentResource) {
158
187
  };
159
188
  }
160
189
 
161
- function AgentEditorDialog({ resource, trigger, onSaved }: AgentEditorProps) {
190
+ function AgentEditorDialog({
191
+ resource,
192
+ trigger,
193
+ onSaved,
194
+ open: controlledOpen,
195
+ onOpenChange: controlledOnOpenChange,
196
+ }: AgentEditorProps) {
162
197
  const isEditing = Boolean(resource);
163
- const [open, setOpen] = useState(false);
198
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
199
+ const open = controlledOpen ?? uncontrolledOpen;
200
+ const setOpen = controlledOnOpenChange ?? setUncontrolledOpen;
164
201
  const [name, setName] = useState("");
165
202
  const [description, setDescription] = useState("");
166
203
  const [instructions, setInstructions] = useState("");
@@ -231,14 +268,16 @@ function AgentEditorDialog({ resource, trigger, onSaved }: AgentEditorProps) {
231
268
 
232
269
  return (
233
270
  <Dialog open={open} onOpenChange={setOpen}>
234
- <DialogTrigger asChild>
235
- {trigger || (
236
- <Button>
271
+ {trigger ? (
272
+ <DialogTrigger asChild>{trigger}</DialogTrigger>
273
+ ) : controlledOpen === undefined ? (
274
+ <DialogTrigger asChild>
275
+ <Button size="sm">
237
276
  <IconPlus size={16} />
238
277
  Create agent
239
278
  </Button>
240
- )}
241
- </DialogTrigger>
279
+ </DialogTrigger>
280
+ ) : null}
242
281
  <DialogContent className="max-w-2xl">
243
282
  <DialogHeader>
244
283
  <DialogTitle>
@@ -355,12 +394,18 @@ function AgentPackDialog({
355
394
  resource,
356
395
  onChanged,
357
396
  trigger,
397
+ open: controlledOpen,
398
+ onOpenChange: controlledOnOpenChange,
358
399
  }: {
359
400
  resource: WorkspaceAgentResource;
360
401
  onChanged?: () => void;
361
402
  trigger?: ReactNode;
403
+ open?: boolean;
404
+ onOpenChange?: (open: boolean) => void;
362
405
  }) {
363
- const [open, setOpen] = useState(false);
406
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
407
+ const open = controlledOpen ?? uncontrolledOpen;
408
+ const setOpen = controlledOnOpenChange ?? setUncontrolledOpen;
364
409
  const [selectedId, setSelectedId] = useState(resource.id);
365
410
  const [content, setContent] = useState(resource.content);
366
411
  const [addOpen, setAddOpen] = useState(false);
@@ -460,14 +505,16 @@ function AgentPackDialog({
460
505
  if (!nextOpen) setAddOpen(false);
461
506
  }}
462
507
  >
463
- <DialogTrigger asChild>
464
- {trigger || (
508
+ {trigger ? (
509
+ <DialogTrigger asChild>{trigger}</DialogTrigger>
510
+ ) : controlledOpen === undefined ? (
511
+ <DialogTrigger asChild>
465
512
  <Button variant="ghost" size="sm">
466
513
  <IconFolder size={15} />
467
514
  Pack
468
515
  </Button>
469
- )}
470
- </DialogTrigger>
516
+ </DialogTrigger>
517
+ ) : null}
471
518
  <DialogContent className="max-w-4xl">
472
519
  <DialogHeader>
473
520
  <DialogTitle>Agent pack</DialogTitle>
@@ -753,7 +800,7 @@ function ImportAgentDialog({ onImported }: { onImported?: () => void }) {
753
800
  }}
754
801
  >
755
802
  <DialogTrigger asChild>
756
- <Button variant="outline">
803
+ <Button variant="outline" size="sm">
757
804
  <IconFileImport size={16} />
758
805
  Import or connect
759
806
  </Button>
@@ -957,10 +1004,17 @@ function ImportAgentDialog({ onImported }: { onImported?: () => void }) {
957
1004
  function DeleteAgentButton({
958
1005
  resource,
959
1006
  onDeleted,
1007
+ open: controlledOpen,
1008
+ onOpenChange: controlledOnOpenChange,
960
1009
  }: {
961
1010
  resource: WorkspaceAgentResource;
962
1011
  onDeleted?: () => void;
1012
+ open?: boolean;
1013
+ onOpenChange?: (open: boolean) => void;
963
1014
  }) {
1015
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
1016
+ const open = controlledOpen ?? uncontrolledOpen;
1017
+ const setOpen = controlledOnOpenChange ?? setUncontrolledOpen;
964
1018
  const remove = useActionMutation("delete-workspace-resource", {
965
1019
  onSuccess: () => {
966
1020
  toast.success("Agent removed");
@@ -970,16 +1024,7 @@ function DeleteAgentButton({
970
1024
  });
971
1025
 
972
1026
  return (
973
- <AlertDialog>
974
- <AlertDialogTrigger asChild>
975
- <Button
976
- variant="ghost"
977
- size="icon"
978
- aria-label={`Remove ${resource.name}`}
979
- >
980
- <IconTrash size={16} />
981
- </Button>
982
- </AlertDialogTrigger>
1027
+ <AlertDialog open={open} onOpenChange={setOpen}>
983
1028
  <AlertDialogContent>
984
1029
  <AlertDialogHeader>
985
1030
  <AlertDialogTitle>Remove {resource.name}?</AlertDialogTitle>
@@ -1011,6 +1056,9 @@ function AgentRow({
1011
1056
  onSaved?: () => void;
1012
1057
  }) {
1013
1058
  const navigate = useNavigate();
1059
+ const [packOpen, setPackOpen] = useState(false);
1060
+ const [editorOpen, setEditorOpen] = useState(false);
1061
+ const [deleteOpen, setDeleteOpen] = useState(false);
1014
1062
  const packQuery = useActionQuery<AgentPackResponse>(
1015
1063
  "list-agent-pack",
1016
1064
  { agentId: resource.id },
@@ -1072,10 +1120,13 @@ function AgentRow({
1072
1120
  }
1073
1121
 
1074
1122
  return (
1075
- <div className="flex items-start gap-3 rounded-xl border bg-card px-4 py-3">
1076
- <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
1077
- <IconUser size={18} />
1078
- </div>
1123
+ <div className="flex min-w-0 items-start gap-3 rounded-2xl border bg-card px-4 py-4">
1124
+ <AppIcon
1125
+ id={resource.id}
1126
+ name={resource.name}
1127
+ icon={agentIconKey(resource)}
1128
+ size="sm"
1129
+ />
1079
1130
  <div className="min-w-0 flex-1">
1080
1131
  <div className="flex flex-wrap items-center gap-2">
1081
1132
  <span className="text-sm font-medium text-foreground">
@@ -1090,11 +1141,8 @@ function AgentRow({
1090
1141
  {resource.description}
1091
1142
  </div>
1092
1143
  ) : null}
1093
- <div className="mt-1 font-mono text-[11px] text-muted-foreground/70">
1094
- {resource.path}
1095
- </div>
1096
1144
  </div>
1097
- <div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
1145
+ <div className="flex shrink-0 items-center gap-1">
1098
1146
  <Button
1099
1147
  variant="outline"
1100
1148
  size="sm"
@@ -1104,43 +1152,63 @@ function AgentRow({
1104
1152
  <IconMessageCircle size={15} />
1105
1153
  Chat
1106
1154
  </Button>
1107
- <Button
1108
- variant="ghost"
1109
- size="sm"
1110
- onClick={() => void buildApp()}
1111
- disabled={promote.isPending}
1112
- aria-label={`Build an app for ${resource.name}`}
1113
- >
1114
- <IconLayoutGrid size={15} />
1115
- {promote.isPending ? "Starting..." : "Build app"}
1116
- </Button>
1117
- <AgentPackDialog
1118
- resource={resource}
1119
- onChanged={onSaved}
1120
- trigger={
1155
+ <DropdownMenu>
1156
+ <DropdownMenuTrigger asChild>
1121
1157
  <Button
1122
1158
  variant="ghost"
1123
1159
  size="icon"
1124
- aria-label={`Manage files for ${resource.name}`}
1160
+ aria-label={`More actions for ${resource.name}`}
1161
+ title="More actions"
1125
1162
  >
1126
- <IconFolder size={16} />
1163
+ <IconDotsVertical size={16} />
1127
1164
  </Button>
1128
- }
1165
+ </DropdownMenuTrigger>
1166
+ <DropdownMenuContent align="end" className="w-48">
1167
+ <DropdownMenuItem
1168
+ onSelect={() => {
1169
+ void buildApp();
1170
+ }}
1171
+ disabled={promote.isPending}
1172
+ >
1173
+ <IconLayoutGrid className="me-2 size-4" />
1174
+ {promote.isPending ? "Starting..." : "Build app"}
1175
+ </DropdownMenuItem>
1176
+ <DropdownMenuItem onSelect={() => setPackOpen(true)}>
1177
+ <IconFolder className="me-2 size-4" />
1178
+ Manage files
1179
+ </DropdownMenuItem>
1180
+ <DropdownMenuItem onSelect={() => setEditorOpen(true)}>
1181
+ <IconEdit className="me-2 size-4" />
1182
+ Edit
1183
+ </DropdownMenuItem>
1184
+ <DropdownMenuSeparator />
1185
+ <DropdownMenuItem
1186
+ className="text-destructive focus:text-destructive"
1187
+ onSelect={() => setDeleteOpen(true)}
1188
+ >
1189
+ <IconTrash className="me-2 size-4" />
1190
+ Remove
1191
+ </DropdownMenuItem>
1192
+ </DropdownMenuContent>
1193
+ </DropdownMenu>
1194
+ <AgentPackDialog
1195
+ resource={resource}
1196
+ onChanged={onSaved}
1197
+ open={packOpen}
1198
+ onOpenChange={setPackOpen}
1129
1199
  />
1130
1200
  <AgentEditorDialog
1131
1201
  resource={resource}
1132
1202
  onSaved={onSaved}
1133
- trigger={
1134
- <Button
1135
- variant="ghost"
1136
- size="icon"
1137
- aria-label={`Edit ${resource.name}`}
1138
- >
1139
- <IconEdit size={16} />
1140
- </Button>
1141
- }
1203
+ open={editorOpen}
1204
+ onOpenChange={setEditorOpen}
1205
+ />
1206
+ <DeleteAgentButton
1207
+ resource={resource}
1208
+ onDeleted={onDeleted}
1209
+ open={deleteOpen}
1210
+ onOpenChange={setDeleteOpen}
1142
1211
  />
1143
- <DeleteAgentButton resource={resource} onDeleted={onDeleted} />
1144
1212
  </div>
1145
1213
  </div>
1146
1214
  );
@@ -1160,12 +1228,18 @@ interface AgentAppCreationResult {
1160
1228
  url?: string;
1161
1229
  }
1162
1230
 
1163
- export function SimpleAgentsPanel() {
1231
+ export function SimpleAgentsPanel({
1232
+ title,
1233
+ }: {
1234
+ title?: ReactNode;
1235
+ } = {}) {
1164
1236
  const query = useActionQuery<WorkspaceAgentResource[]>(
1165
1237
  "list-workspace-resources",
1166
1238
  { kind: "agent" },
1167
1239
  );
1168
1240
  const agents = query.data ?? [];
1241
+ const refreshAgents = () => void query.refetch();
1242
+ const importAction = <ImportAgentDialog onImported={refreshAgents} />;
1169
1243
 
1170
1244
  if (query.isError) {
1171
1245
  return (
@@ -1178,21 +1252,32 @@ export function SimpleAgentsPanel() {
1178
1252
 
1179
1253
  return (
1180
1254
  <section className="flex flex-col gap-4">
1181
- <div className="flex flex-wrap items-center justify-end gap-2">
1182
- <ImportAgentDialog onImported={() => void query.refetch()} />
1183
- <AgentEditorDialog onSaved={() => void query.refetch()} />
1184
- </div>
1255
+ {title || agents.length > 0 ? (
1256
+ <div
1257
+ className={`flex flex-wrap items-center gap-3 ${title ? "justify-between" : "justify-end"}`}
1258
+ >
1259
+ {title ? (
1260
+ <h2 className="text-base font-medium text-foreground">{title}</h2>
1261
+ ) : null}
1262
+ {agents.length > 0 ? (
1263
+ <div className="flex flex-wrap items-center justify-end gap-2">
1264
+ {importAction}
1265
+ <AgentEditorDialog onSaved={refreshAgents} />
1266
+ </div>
1267
+ ) : null}
1268
+ </div>
1269
+ ) : null}
1185
1270
  {query.isLoading && agents.length === 0 ? (
1186
- <div className="flex flex-col gap-3">
1271
+ <div className="grid gap-3 md:grid-cols-2">
1187
1272
  {[0, 1, 2].map((item) => (
1188
- <div key={item} className="rounded-xl border bg-card px-4 py-3">
1273
+ <div key={item} className="rounded-2xl border bg-card px-4 py-4">
1189
1274
  <Skeleton className="h-4 w-1/3" />
1190
1275
  <Skeleton className="mt-2 h-3 w-2/3" />
1191
1276
  </div>
1192
1277
  ))}
1193
1278
  </div>
1194
1279
  ) : agents.length > 0 ? (
1195
- <div className="flex flex-col gap-2">
1280
+ <div className="grid gap-3 md:grid-cols-2">
1196
1281
  {agents.map((agent) => (
1197
1282
  <AgentRow
1198
1283
  key={agent.id}
@@ -1203,21 +1288,21 @@ export function SimpleAgentsPanel() {
1203
1288
  ))}
1204
1289
  </div>
1205
1290
  ) : (
1206
- <div className="rounded-xl border border-dashed px-6 py-14 text-center">
1291
+ <div className="rounded-2xl border border-dashed bg-card px-4 py-12 text-center">
1207
1292
  <div className="text-sm font-medium text-foreground">
1208
1293
  No agents yet
1209
1294
  </div>
1210
1295
  <div className="mt-4 flex flex-wrap justify-center gap-2">
1211
1296
  <AgentEditorDialog
1212
- onSaved={() => void query.refetch()}
1297
+ onSaved={refreshAgents}
1213
1298
  trigger={
1214
- <Button>
1299
+ <Button size="sm">
1215
1300
  <IconPlus size={16} />
1216
1301
  Create an agent
1217
1302
  </Button>
1218
1303
  }
1219
1304
  />
1220
- <ImportAgentDialog onImported={() => void query.refetch()} />
1305
+ {importAction}
1221
1306
  </div>
1222
1307
  </div>
1223
1308
  )}