@agent-native/dispatch 0.11.5 → 0.11.7

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.
@@ -12,6 +12,7 @@
12
12
  * Options:
13
13
  * --view View name to navigate to
14
14
  * --path URL path to navigate to
15
+ * --threadId Chat thread ID to open on the chat route
15
16
  */
16
17
 
17
18
  import { defineAction } from "@agent-native/core";
@@ -20,7 +21,7 @@ import { writeAppState } from "@agent-native/core/application-state";
20
21
 
21
22
  export default defineAction({
22
23
  description:
23
- "Navigate the UI to a specific view or path. Writes a navigate command to application state which the UI reads and auto-deletes.",
24
+ "Navigate the UI to a specific view or path. Use threadId to open a specific chat thread on the chat route. Writes a navigate command to application state which the UI reads and auto-deletes.",
24
25
  schema: z.object({
25
26
  view: z
26
27
  .string()
@@ -29,16 +30,24 @@ export default defineAction({
29
30
  "Named dispatch view to navigate to. Built-in views include chat, overview, apps, metrics, new-app, vault, integrations, messaging, workspace, agents, destinations, identities, approvals, audit, thread-debug, dreams, and team. Generated Dispatch extension tabs can also use their nav item id.",
30
31
  ),
31
32
  path: z.string().optional().describe("URL path to navigate to"),
33
+ threadId: z
34
+ .string()
35
+ .optional()
36
+ .describe("Chat thread ID to open on the chat route"),
32
37
  }),
33
38
  http: false,
34
39
  run: async (args) => {
35
- if (!args.view && !args.path) {
36
- return "Error: At least --view or --path is required.";
40
+ const threadId = args.threadId?.trim();
41
+ if (!args.view && !args.path && !threadId) {
42
+ return "Error: At least --view, --path, or --threadId is required.";
37
43
  }
38
44
  const nav: Record<string, string> = {};
45
+ // A thread id without an explicit view implies the chat surface.
39
46
  if (args.view) nav.view = args.view;
47
+ else if (threadId) nav.view = "chat";
40
48
  if (args.path) nav.path = args.path;
49
+ if (threadId) nav.threadId = threadId;
41
50
  await writeAppState("navigate", nav);
42
- return `Navigating to ${args.view || args.path}`;
51
+ return `Navigating to ${args.view || args.path || `chat thread ${threadId}`}`;
43
52
  },
44
53
  });
@@ -41,5 +41,8 @@ export default defineAction({
41
41
  description !== undefined,
42
42
  "At least one secret field must be updated",
43
43
  ),
44
+ // Carries a secret `value`. Record THAT the secret changed, never the value —
45
+ // keep the audit trail from becoming a second credential store.
46
+ audit: { recordInputs: false },
44
47
  run: async (args) => updateSecret(args.id, args),
45
48
  });
@@ -286,6 +286,21 @@ function dispatchNavLinkTarget(path: string): string {
286
286
  return routerHasBasename ? path : appPath(path);
287
287
  }
288
288
 
289
+ function chatThreadPath(threadId: string): string {
290
+ return `/chat/${encodeURIComponent(threadId)}`;
291
+ }
292
+
293
+ function threadIdFromPath(pathname: string): string | null {
294
+ const match = pathname.match(/^\/chat\/([^/]+)/);
295
+ if (!match) return null;
296
+ try {
297
+ const value = decodeURIComponent(match[1]).trim();
298
+ return value || null;
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
289
304
  function formatThreadAge(updatedAt: number) {
290
305
  const diffMs = Math.max(0, Date.now() - updatedAt);
291
306
  const minutes = Math.floor(diffMs / 60_000);
@@ -315,6 +330,7 @@ function threadUpdatedAt(thread: ChatThreadSummary) {
315
330
 
316
331
  function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
317
332
  const navigate = useNavigate();
333
+ const location = useLocation();
318
334
  const {
319
335
  threads,
320
336
  activeThreadId,
@@ -370,7 +386,9 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
370
386
  switchThread(threadId);
371
387
  navigateWithAgentChatViewTransition(
372
388
  navigate,
373
- dispatchNavLinkTarget("/chat"),
389
+ dispatchNavLinkTarget(
390
+ options?.isNew ? "/chat" : chatThreadPath(threadId),
391
+ ),
374
392
  );
375
393
  onNavigate?.();
376
394
  window.requestAnimationFrame(() => {
@@ -439,7 +457,11 @@ function DispatchChatsSection({ onNavigate }: { onNavigate?: () => void }) {
439
457
  <div className="grid gap-0.5">
440
458
  {visibleThreads.length > 0 ? (
441
459
  visibleThreads.map((thread) => {
442
- const isActive = thread.id === activeThreadId;
460
+ const localPathname = localDispatchPath(location.pathname);
461
+ const isActive =
462
+ thread.id ===
463
+ (threadIdFromPath(localPathname) ??
464
+ (localPathname === "/chat" ? null : activeThreadId));
443
465
  const isRenaming = thread.id === renamingThreadId;
444
466
  return (
445
467
  <div
@@ -692,13 +714,18 @@ export function Layout({
692
714
  const navigate = useNavigate();
693
715
  const [mobileOpen, setMobileOpen] = useState(false);
694
716
  const localPathname = localDispatchPath(location.pathname);
695
- const isChatRoute = localPathname === "/chat";
717
+ const isChatRoute =
718
+ localPathname === "/chat" || localPathname.startsWith("/chat/");
696
719
  const chatHomeHandoffActive = useAgentChatHomeHandoff({
697
720
  storageKey: "dispatch",
698
721
  activePath: localPathname,
699
722
  enabled: !isChatRoute,
700
723
  });
701
- useAgentChatHomeHandoffLinks({ storageKey: "dispatch", chatPath: "/chat" });
724
+ useAgentChatHomeHandoffLinks({
725
+ storageKey: "dispatch",
726
+ isChatPath: (pathname) =>
727
+ pathname === "/chat" || pathname.startsWith("/chat/"),
728
+ });
702
729
 
703
730
  if (CHROMELESS_PATHS.some((path) => localPathname === path)) {
704
731
  return <>{children}</>;
@@ -21,6 +21,7 @@ export interface NavigationState {
21
21
  dreamId?: string;
22
22
  sourceId?: string;
23
23
  query?: string;
24
+ threadId?: string;
24
25
  }
25
26
 
26
27
  export function useNavigationState(extensions?: DispatchExtensionConfig) {
@@ -82,8 +83,8 @@ export function useNavigationState(extensions?: DispatchExtensionConfig) {
82
83
  : resolvedPath;
83
84
  const nextPath = routerPath(path);
84
85
  if (
85
- routerPath(location.pathname) === "/chat" &&
86
- pathnameFromPath(nextPath) !== "/chat"
86
+ isChatPath(routerPath(location.pathname)) &&
87
+ !isChatPath(pathnameFromPath(nextPath))
87
88
  ) {
88
89
  markAgentChatHomeHandoff("dispatch");
89
90
  }
@@ -96,6 +97,21 @@ function pathnameFromPath(path: string): string {
96
97
  return path.split(/[?#]/, 1)[0] || "/";
97
98
  }
98
99
 
100
+ function isChatPath(pathname: string): boolean {
101
+ return pathname === "/chat" || pathname.startsWith("/chat/");
102
+ }
103
+
104
+ function threadIdFromPath(pathname: string): string | null {
105
+ const match = pathname.match(/^\/chat\/([^/]+)/);
106
+ if (!match) return null;
107
+ try {
108
+ const value = decodeURIComponent(match[1]).trim();
109
+ return value || null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
99
115
  export function buildDispatchNavigationState(
100
116
  pathname: string,
101
117
  search = "",
@@ -106,6 +122,9 @@ export function buildDispatchNavigationState(
106
122
  path: appPath(pathname),
107
123
  };
108
124
 
125
+ const threadId = threadIdFromPath(pathname);
126
+ if (threadId) state.threadId = threadId;
127
+
109
128
  const extensionId = extensionIdFromPathname(pathname);
110
129
  if (extensionId) {
111
130
  state.view = "extensions";
@@ -206,12 +225,14 @@ function resolveView(
206
225
  function resolvePath(
207
226
  view?: string,
208
227
  extensions?: DispatchExtensionConfig,
209
- command?: Pick<NavigationState, "extensionId">,
228
+ command?: Pick<NavigationState, "extensionId" | "threadId">,
210
229
  ): string | undefined {
211
230
  switch (view) {
212
231
  case "chat":
213
232
  case "ask":
214
- return "/chat";
233
+ return command?.threadId && command.threadId.trim()
234
+ ? `/chat/${encodeURIComponent(command.threadId.trim())}`
235
+ : "/chat";
215
236
  case "overview":
216
237
  return "/overview";
217
238
  case "apps":
@@ -32,6 +32,7 @@ import { type RouteConfig, route, index } from "@react-router/dev/routes";
32
32
  export const dispatchRoutes: RouteConfig = [
33
33
  index("./pages/_index.js"),
34
34
  route("chat", "./pages/chat.js"),
35
+ route("chat/:threadId", "./pages/chat.js"),
35
36
  route("overview", "./pages/overview.js"),
36
37
  route("metrics", "./pages/metrics.js"),
37
38
  route("apps", "./pages/apps.js"),
@@ -1,11 +1,53 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { useLocation, useNavigate } from "react-router";
3
3
  import {
4
4
  AgentChatSurface,
5
+ appBasePath,
6
+ appPath,
5
7
  markAgentChatHomeHandoff,
6
8
  } from "@agent-native/core/client";
7
9
  import { submitOverviewPrompt } from "@/lib/overview-chat";
8
10
 
11
+ function chatThreadPath(threadId: string | null): string {
12
+ return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/chat";
13
+ }
14
+
15
+ function stripBasePath(pathname: string): string {
16
+ const basePath = appBasePath();
17
+ if (!basePath) return pathname;
18
+ if (pathname === basePath) return "/";
19
+ if (pathname.startsWith(`${basePath}/`)) {
20
+ return pathname.slice(basePath.length) || "/";
21
+ }
22
+ return pathname;
23
+ }
24
+
25
+ // The chat surface renders for both `/chat` and the `/chat/:threadId` deep
26
+ // link. The thread id is read from the pathname (not `useParams`) because the
27
+ // param is owned by the nested deep-link route, not this leaf component.
28
+ function threadIdFromPath(pathname: string): string | null {
29
+ const match = stripBasePath(pathname).match(/^\/chat\/([^/]+)/);
30
+ if (!match) return null;
31
+ try {
32
+ const value = decodeURIComponent(match[1]).trim();
33
+ return value || null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // Mirror the basename handling Dispatch's nav links use: pass a router-local
40
+ // path when the live URL is already under the mount, otherwise prefix it.
41
+ function dispatchNavTarget(path: string): string {
42
+ if (typeof window === "undefined") return path;
43
+ const basePath = appBasePath();
44
+ if (!basePath) return path;
45
+ const pathname = window.location.pathname;
46
+ const routerHasBasename =
47
+ pathname === basePath || pathname.startsWith(`${basePath}/`);
48
+ return routerHasBasename ? path : appPath(path);
49
+ }
50
+
9
51
  interface DispatchChatLocationState {
10
52
  dispatchPrompt?: {
11
53
  id?: string | number;
@@ -25,7 +67,22 @@ export function meta() {
25
67
  export default function ChatRoute() {
26
68
  const location = useLocation();
27
69
  const navigate = useNavigate();
70
+ const routeThreadId = threadIdFromPath(location.pathname);
28
71
  const handledStateIds = useRef(new Set<string>());
72
+
73
+ const navigateThreadUrl = useCallback(
74
+ (path: string, options?: { replace?: boolean }) =>
75
+ navigate(dispatchNavTarget(path), options),
76
+ [navigate],
77
+ );
78
+ const threadUrlSync = useMemo(
79
+ () => ({
80
+ routeThreadId: routeThreadId ?? null,
81
+ getPath: chatThreadPath,
82
+ navigate: navigateThreadUrl,
83
+ }),
84
+ [routeThreadId, navigateThreadUrl],
85
+ );
29
86
  const state = location.state as DispatchChatLocationState | null;
30
87
  const prompt = state?.dispatchPrompt;
31
88
  const thread = state?.dispatchThread;
@@ -92,6 +149,7 @@ export default function ChatRoute() {
92
149
  className="dispatch-chat-panel"
93
150
  defaultMode="chat"
94
151
  storageKey="dispatch"
152
+ threadUrlSync={threadUrlSync}
95
153
  showHeader={false}
96
154
  showTabBar={false}
97
155
  dynamicSuggestions={false}