@agent-native/dispatch 0.2.2 → 0.2.4

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.
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from "react";
1
+ import { useRef, useState, type FormEvent } from "react";
2
2
  import { Button } from "@/components/ui/button";
3
3
  import { Input } from "@/components/ui/input";
4
4
  import {
@@ -27,6 +27,48 @@ export interface ConnectedAgent {
27
27
  scope?: "shared" | "personal";
28
28
  }
29
29
 
30
+ type AgentFormErrors = Partial<Record<"name" | "url" | "form", string>>;
31
+
32
+ function slugifyAgentName(value: string): string {
33
+ return value
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, "-")
37
+ .replace(/^-+|-+$/g, "");
38
+ }
39
+
40
+ function validateAgentForm(name: string, url: string): AgentFormErrors {
41
+ const errors: AgentFormErrors = {};
42
+ const trimmedName = name.trim();
43
+ const trimmedUrl = url.trim();
44
+
45
+ if (!trimmedName) {
46
+ errors.name = "Agent name is required.";
47
+ } else if (!slugifyAgentName(trimmedName)) {
48
+ errors.name = "Agent name must include at least one letter or number.";
49
+ }
50
+
51
+ if (!trimmedUrl) {
52
+ errors.url = "Agent endpoint URL is required.";
53
+ } else {
54
+ try {
55
+ const parsed = new URL(trimmedUrl);
56
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
57
+ errors.url = "Use an http:// or https:// endpoint URL.";
58
+ } else if (!parsed.hostname) {
59
+ errors.url = "Enter a complete endpoint URL with a host.";
60
+ } else if (parsed.username || parsed.password) {
61
+ errors.url = "Do not include credentials in the endpoint URL.";
62
+ }
63
+ } catch {
64
+ errors.url =
65
+ "Enter a valid endpoint URL, such as https://app.example.com.";
66
+ }
67
+ }
68
+
69
+ return errors;
70
+ }
71
+
30
72
  export function AgentsPanel({
31
73
  agents,
32
74
  onRefresh,
@@ -38,6 +80,7 @@ export function AgentsPanel({
38
80
  const [url, setUrl] = useState("");
39
81
  const [description, setDescription] = useState("");
40
82
  const [saving, setSaving] = useState(false);
83
+ const [errors, setErrors] = useState<AgentFormErrors>({});
41
84
  const nameRef = useRef<HTMLInputElement>(null);
42
85
 
43
86
  const customAgents = agents.filter((agent) => agent.source === "custom");
@@ -46,12 +89,17 @@ export function AgentsPanel({
46
89
  );
47
90
  const builtinAgents = agents.filter((agent) => agent.source === "builtin");
48
91
 
49
- const handleAdd = async () => {
92
+ const handleAdd = async (event?: FormEvent<HTMLFormElement>) => {
93
+ event?.preventDefault();
50
94
  const trimmedName = name.trim();
51
95
  const trimmedUrl = url.trim();
52
- if (!trimmedName || !trimmedUrl) return;
96
+ const nextErrors = validateAgentForm(trimmedName, trimmedUrl);
97
+ if (Object.keys(nextErrors).length > 0) {
98
+ setErrors(nextErrors);
99
+ return;
100
+ }
53
101
 
54
- const id = trimmedName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
102
+ const id = slugifyAgentName(trimmedName);
55
103
  const agentJson = JSON.stringify(
56
104
  {
57
105
  id,
@@ -79,9 +127,21 @@ export function AgentsPanel({
79
127
  setName("");
80
128
  setUrl("");
81
129
  setDescription("");
130
+ setErrors({});
82
131
  onRefresh();
83
132
  nameRef.current?.focus();
133
+ } else {
134
+ setErrors({
135
+ form: `Could not add agent. Request failed with ${res.status}.`,
136
+ });
84
137
  }
138
+ } catch (error) {
139
+ setErrors({
140
+ form:
141
+ error instanceof Error
142
+ ? error.message
143
+ : "Could not add agent. Please try again.",
144
+ });
85
145
  } finally {
86
146
  setSaving(false);
87
147
  }
@@ -230,31 +290,66 @@ export function AgentsPanel({
230
290
  <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
231
291
  Add another A2A-compatible app by saving its agent endpoint here.
232
292
  </p>
233
- <div className="mt-4 space-y-3">
234
- <Input
235
- ref={nameRef}
236
- value={name}
237
- onChange={(event) => setName(event.target.value)}
238
- placeholder="Name"
239
- />
240
- <Input
241
- value={url}
242
- onChange={(event) => setUrl(event.target.value)}
243
- placeholder="https://app.example.com"
244
- />
293
+ <form className="mt-4 space-y-3" onSubmit={handleAdd} noValidate>
294
+ <div className="space-y-1.5">
295
+ <Input
296
+ ref={nameRef}
297
+ value={name}
298
+ onChange={(event) => {
299
+ setName(event.target.value);
300
+ setErrors((current) => ({ ...current, name: undefined }));
301
+ }}
302
+ placeholder="Name"
303
+ aria-invalid={Boolean(errors.name)}
304
+ aria-describedby={
305
+ errors.name ? "external-agent-name-error" : undefined
306
+ }
307
+ />
308
+ {errors.name ? (
309
+ <p
310
+ id="external-agent-name-error"
311
+ className="text-xs font-medium text-destructive"
312
+ >
313
+ {errors.name}
314
+ </p>
315
+ ) : null}
316
+ </div>
317
+ <div className="space-y-1.5">
318
+ <Input
319
+ value={url}
320
+ onChange={(event) => {
321
+ setUrl(event.target.value);
322
+ setErrors((current) => ({ ...current, url: undefined }));
323
+ }}
324
+ placeholder="https://app.example.com"
325
+ aria-invalid={Boolean(errors.url)}
326
+ aria-describedby={
327
+ errors.url ? "external-agent-url-error" : undefined
328
+ }
329
+ />
330
+ {errors.url ? (
331
+ <p
332
+ id="external-agent-url-error"
333
+ className="text-xs font-medium text-destructive"
334
+ >
335
+ {errors.url}
336
+ </p>
337
+ ) : null}
338
+ </div>
245
339
  <Input
246
340
  value={description}
247
341
  onChange={(event) => setDescription(event.target.value)}
248
342
  placeholder="Description (optional)"
249
343
  />
250
- <Button
251
- className="w-full"
252
- onClick={handleAdd}
253
- disabled={!name.trim() || !url.trim() || saving}
254
- >
344
+ {errors.form ? (
345
+ <p className="text-xs font-medium text-destructive">
346
+ {errors.form}
347
+ </p>
348
+ ) : null}
349
+ <Button type="submit" className="w-full" disabled={saving}>
255
350
  {saving ? "Saving..." : "Add agent"}
256
351
  </Button>
257
- </div>
352
+ </form>
258
353
  </div>
259
354
  </div>
260
355
  </section>
@@ -44,7 +44,7 @@ export function Header({
44
44
  <Button
45
45
  variant="ghost"
46
46
  size="icon"
47
- className="h-8 w-8 2xl:hidden cursor-pointer"
47
+ className="h-8 w-8 lg:hidden cursor-pointer"
48
48
  onClick={onOpenMobile}
49
49
  aria-label="Open navigation"
50
50
  >
@@ -327,8 +327,6 @@ export function Layout({
327
327
  }) {
328
328
  const location = useLocation();
329
329
  const [mobileOpen, setMobileOpen] = useState(false);
330
- const hasEmbeddedAgentChat =
331
- location.pathname === "/" || location.pathname === "/overview";
332
330
 
333
331
  if (CHROMELESS_PATHS.some((path) => location.pathname === path)) {
334
332
  return <>{children}</>;
@@ -337,12 +335,7 @@ export function Layout({
337
335
  const showHeader = !pageOwnsToolbar(location.pathname);
338
336
  const appContent = (
339
337
  <div className="flex h-full flex-1 flex-col overflow-hidden">
340
- {showHeader ? (
341
- <Header
342
- onOpenMobile={() => setMobileOpen(true)}
343
- showAgentToggle={!hasEmbeddedAgentChat}
344
- />
345
- ) : null}
338
+ {showHeader ? <Header onOpenMobile={() => setMobileOpen(true)} /> : null}
346
339
  <InvitationBanner />
347
340
  <main className="flex-1 overflow-y-auto">
348
341
  {showHeader ? (
@@ -359,7 +352,7 @@ export function Layout({
359
352
  return (
360
353
  <HeaderActionsProvider>
361
354
  <div className="flex h-screen w-full overflow-hidden bg-background">
362
- <aside className="hidden 2xl:flex w-64 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground">
355
+ <aside className="hidden lg:flex w-64 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground">
363
356
  <NavContent extensions={extensions} />
364
357
  </aside>
365
358
 
@@ -383,8 +376,7 @@ export function Layout({
383
376
 
384
377
  {/*
385
378
  * Always mount AgentSidebar so home composer's sendToAgentChat
386
- * fallback can pop it via agent-panel:open. The toggle button stays
387
- * hidden on overview because the home composer is the primary input.
379
+ * fallback can pop it via agent-panel:open.
388
380
  */}
389
381
  <AgentSidebar
390
382
  position="right"
@@ -307,24 +307,28 @@ const SidebarRail = React.forwardRef<
307
307
  const { toggleSidebar } = useSidebar();
308
308
 
309
309
  return (
310
- <button
311
- ref={ref}
312
- data-sidebar="rail"
313
- aria-label="Toggle Sidebar"
314
- tabIndex={-1}
315
- onClick={toggleSidebar}
316
- title="Toggle Sidebar"
317
- className={cn(
318
- "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
319
- "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
320
- "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
321
- "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
322
- "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
323
- "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
324
- className,
325
- )}
326
- {...props}
327
- />
310
+ <Tooltip>
311
+ <TooltipTrigger asChild>
312
+ <button
313
+ ref={ref}
314
+ data-sidebar="rail"
315
+ aria-label="Toggle Sidebar"
316
+ tabIndex={-1}
317
+ onClick={toggleSidebar}
318
+ className={cn(
319
+ "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
320
+ "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
321
+ "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
322
+ "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
323
+ "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
324
+ "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
325
+ className,
326
+ )}
327
+ {...props}
328
+ />
329
+ </TooltipTrigger>
330
+ <TooltipContent>Toggle Sidebar</TooltipContent>
331
+ </Tooltip>
328
332
  );
329
333
  });
330
334
  SidebarRail.displayName = "SidebarRail";
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const frameState = vi.hoisted(() => ({ inBuilderFrame: false }));
4
+ const sendToAgentChatMock = vi.hoisted(() => vi.fn(() => "chat-tab"));
5
+
6
+ vi.mock("@agent-native/core/client", () => ({
7
+ isInBuilderFrame: () => frameState.inBuilderFrame,
8
+ sendToAgentChat: sendToAgentChatMock,
9
+ }));
10
+
11
+ const { submitOverviewPrompt } = await import("./overview-chat.js");
12
+
13
+ describe("submitOverviewPrompt", () => {
14
+ beforeEach(() => {
15
+ frameState.inBuilderFrame = false;
16
+ sendToAgentChatMock.mockClear();
17
+ });
18
+
19
+ it("sends overview prompts to a new local agent tab outside Builder", () => {
20
+ const tabId = submitOverviewPrompt(" build a metrics app ", "auto");
21
+
22
+ expect(tabId).toBe("chat-tab");
23
+ expect(sendToAgentChatMock).toHaveBeenCalledWith({
24
+ message: "build a metrics app",
25
+ submit: true,
26
+ newTab: true,
27
+ model: "auto",
28
+ });
29
+ });
30
+
31
+ it("routes overview prompts to Builder chat inside Builder", () => {
32
+ frameState.inBuilderFrame = true;
33
+
34
+ const tabId = submitOverviewPrompt("ship the onboarding flow", "auto");
35
+
36
+ expect(tabId).toBe("chat-tab");
37
+ expect(sendToAgentChatMock).toHaveBeenCalledWith({
38
+ message: "ship the onboarding flow",
39
+ submit: true,
40
+ type: "code",
41
+ });
42
+ });
43
+
44
+ it("ignores empty prompts", () => {
45
+ expect(submitOverviewPrompt(" ", "auto")).toBeNull();
46
+ expect(sendToAgentChatMock).not.toHaveBeenCalled();
47
+ });
48
+ });
@@ -0,0 +1,24 @@
1
+ import { isInBuilderFrame, sendToAgentChat } from "@agent-native/core/client";
2
+
3
+ export function submitOverviewPrompt(
4
+ message: string,
5
+ selectedModel?: string | null,
6
+ ): string | null {
7
+ const trimmed = message.trim();
8
+ if (!trimmed) return null;
9
+
10
+ if (isInBuilderFrame()) {
11
+ return sendToAgentChat({
12
+ message: trimmed,
13
+ submit: true,
14
+ type: "code",
15
+ });
16
+ }
17
+
18
+ return sendToAgentChat({
19
+ message: trimmed,
20
+ submit: true,
21
+ newTab: true,
22
+ model: selectedModel || undefined,
23
+ });
24
+ }
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react";
2
2
  import { Link } from "react-router";
3
3
  import {
4
4
  PromptComposer,
5
- sendToAgentChat,
6
5
  useActionQuery,
7
6
  useChatModels,
8
7
  agentNativePath,
@@ -34,6 +33,7 @@ import {
34
33
  TooltipContent,
35
34
  TooltipTrigger,
36
35
  } from "@/components/ui/tooltip";
36
+ import { submitOverviewPrompt } from "@/lib/overview-chat";
37
37
 
38
38
  interface IntegrationStatus {
39
39
  platform: string;
@@ -99,17 +99,12 @@ function HomeChatPanel() {
99
99
  const { selectedModel } = useChatModels();
100
100
 
101
101
  const send = (message: string) => {
102
- sendToAgentChat({
103
- message,
104
- submit: true,
105
- newTab: true,
106
- model: selectedModel || undefined,
107
- });
102
+ submitOverviewPrompt(message, selectedModel);
108
103
  };
109
104
 
110
105
  return (
111
106
  <section className="px-2 py-6 sm:py-10">
112
- <div className="mx-auto w-full max-w-2xl space-y-5">
107
+ <div className="mx-auto w-full max-w-2xl space-y-8">
113
108
  <h1 className="text-center text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
114
109
  What should we do next?
115
110
  </h1>
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const packageRoot = path.resolve(
7
+ path.dirname(fileURLToPath(import.meta.url)),
8
+ "../..",
9
+ );
10
+ const repoRoot = path.resolve(packageRoot, "../..");
11
+
12
+ describe("dispatch Tailwind styles", () => {
13
+ it("exports package source directives for consuming apps", () => {
14
+ const pkg = JSON.parse(
15
+ fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"),
16
+ ) as { exports?: Record<string, string> };
17
+ const stylesheet = fs.readFileSync(
18
+ path.join(packageRoot, "src/styles/dispatch.css"),
19
+ "utf-8",
20
+ );
21
+
22
+ expect(pkg.exports?.["./styles/dispatch.css"]).toBe(
23
+ "./src/styles/dispatch.css",
24
+ );
25
+ expect(stylesheet).toContain(
26
+ '@source "../components/**/*.{js,mjs,ts,tsx}"',
27
+ );
28
+ expect(stylesheet).toContain('@source "../routes/**/*.{js,mjs,ts,tsx}"');
29
+ });
30
+
31
+ it("imports package source directives from the Dispatch template", () => {
32
+ const globalCss = fs.readFileSync(
33
+ path.join(repoRoot, "templates/dispatch/app/global.css"),
34
+ "utf-8",
35
+ );
36
+
37
+ expect(globalCss).toContain(
38
+ '@import "@agent-native/dispatch/styles/dispatch.css";',
39
+ );
40
+ });
41
+ });
42
+
43
+ describe("dispatch route shells", () => {
44
+ it("re-exports the index route redirects from the Dispatch template", () => {
45
+ const indexRoute = fs.readFileSync(
46
+ path.join(repoRoot, "templates/dispatch/app/routes/_index.tsx"),
47
+ "utf-8",
48
+ );
49
+
50
+ expect(indexRoute).toContain("loader");
51
+ expect(indexRoute).toContain("clientLoader");
52
+ expect(indexRoute).toContain("HydrateFallback");
53
+ expect(indexRoute).toContain("@agent-native/dispatch/routes/pages/_index");
54
+ });
55
+ });
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Tailwind v4 does not scan package sources in node_modules unless the
3
+ * consuming app opts in. Import this stylesheet from a Dispatch app's global
4
+ * CSS so Tailwind includes the utilities used by packaged Dispatch routes and
5
+ * components.
6
+ */
7
+ @source "../components/**/*.{js,mjs,ts,tsx}";
8
+ @source "../hooks/**/*.{js,mjs,ts,tsx}";
9
+ @source "../routes/**/*.{js,mjs,ts,tsx}";