@agent-native/core 0.72.1 → 0.72.3

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 (37) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AgentPanel.tsx +12 -1
  5. package/corpus/core/src/client/MultiTabAssistantChat.tsx +263 -13
  6. package/corpus/core/src/client/use-agent-chat-home-handoff.ts +16 -4
  7. package/corpus/core/src/client/use-chat-threads.ts +86 -5
  8. package/corpus/templates/assets/actions/navigate.ts +2 -1
  9. package/corpus/templates/assets/app/components/layout/Layout.tsx +3 -2
  10. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +27 -4
  11. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +35 -5
  12. package/corpus/templates/assets/app/routes/_index.tsx +12 -0
  13. package/corpus/templates/assets/app/routes/chat.$threadId.tsx +1 -0
  14. package/corpus/templates/chat/actions/navigate.ts +3 -0
  15. package/corpus/templates/chat/app/components/layout/Layout.tsx +7 -2
  16. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +27 -4
  17. package/corpus/templates/chat/app/hooks/use-navigation-state.ts +38 -6
  18. package/corpus/templates/chat/app/routes/_index.tsx +13 -0
  19. package/corpus/templates/chat/app/routes/chat.$threadId.tsx +1 -0
  20. package/corpus/templates/dispatch/app/routes/chat.$threadId.tsx +26 -0
  21. package/dist/client/AgentPanel.d.ts +6 -2
  22. package/dist/client/AgentPanel.d.ts.map +1 -1
  23. package/dist/client/AgentPanel.js +4 -4
  24. package/dist/client/AgentPanel.js.map +1 -1
  25. package/dist/client/MultiTabAssistantChat.d.ts +22 -1
  26. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  27. package/dist/client/MultiTabAssistantChat.js +215 -13
  28. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  29. package/dist/client/use-agent-chat-home-handoff.d.ts +7 -1
  30. package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
  31. package/dist/client/use-agent-chat-home-handoff.js +9 -5
  32. package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
  33. package/dist/client/use-chat-threads.d.ts +6 -0
  34. package/dist/client/use-chat-threads.d.ts.map +1 -1
  35. package/dist/client/use-chat-threads.js +79 -5
  36. package/dist/client/use-chat-threads.js.map +1 -1
  37. package/package.json +1 -1
@@ -63,6 +63,12 @@ export interface UseChatThreadsOptions {
63
63
  autoCreate?: boolean;
64
64
  /** Restore the active thread from localStorage. Defaults to true. */
65
65
  restoreActiveThread?: boolean;
66
+ /**
67
+ * Route-owned active thread. `undefined` preserves the legacy localStorage
68
+ * source of truth; a string opens that thread; `null` means the URL is in
69
+ * create/new-chat mode.
70
+ */
71
+ routeThreadId?: string | null;
66
72
  }
67
73
 
68
74
  const ACTIVE_THREAD_KEY = "agent-chat-active-thread";
@@ -114,6 +120,12 @@ function createLocalThreadId(): string {
114
120
  return `thread-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
115
121
  }
116
122
 
123
+ function normalizeThreadId(value: string | null | undefined): string | null {
124
+ if (typeof value !== "string") return null;
125
+ const trimmed = value.trim();
126
+ return trimmed || null;
127
+ }
128
+
117
129
  function scopesMatch(
118
130
  a?: ChatThreadScope | null,
119
131
  b?: ChatThreadScope | null,
@@ -155,6 +167,8 @@ export function useChatThreads(
155
167
  ) {
156
168
  const autoCreate = options?.autoCreate !== false;
157
169
  const restoreActiveThread = options?.restoreActiveThread !== false;
170
+ const routeControlsActiveThread = options?.routeThreadId !== undefined;
171
+ const routeThreadId = normalizeThreadId(options?.routeThreadId);
158
172
  // Each (storageKey, scope) pair gets its own active-thread localStorage key
159
173
  // for chats that belong to a resource. General chats keep using the unscoped
160
174
  // key even while the user is looking at a resource, so clicking into a deck,
@@ -180,10 +194,16 @@ export function useChatThreads(
180
194
  let id: string | null = null;
181
195
  let isNew = false;
182
196
  if (typeof window !== "undefined") {
183
- try {
184
- id = restoreActiveThread ? localStorage.getItem(activeThreadKey) : null;
185
- } catch {
186
- id = null;
197
+ if (routeControlsActiveThread) {
198
+ id = routeThreadId;
199
+ } else {
200
+ try {
201
+ id = restoreActiveThread
202
+ ? localStorage.getItem(activeThreadKey)
203
+ : null;
204
+ } catch {
205
+ id = null;
206
+ }
187
207
  }
188
208
  if (!id && autoCreate) {
189
209
  id = createLocalThreadId();
@@ -323,6 +343,10 @@ export function useChatThreads(
323
343
  const persistedKeyRef = useRef(activeThreadKey);
324
344
  useEffect(() => {
325
345
  if (persistedKeyRef.current !== activeThreadKey) {
346
+ if (routeControlsActiveThread) {
347
+ persistedKeyRef.current = activeThreadKey;
348
+ return;
349
+ }
326
350
  const currentId = activeThreadIdRef.current;
327
351
  if (currentId) {
328
352
  const currentThreadScope = readKnownThreadScope(currentId);
@@ -357,6 +381,11 @@ export function useChatThreads(
357
381
  return;
358
382
  }
359
383
  try {
384
+ if (routeControlsActiveThread && !routeThreadId) {
385
+ localStorage.removeItem(activeThreadKey);
386
+ localStorage.removeItem(activeThreadSeenKey);
387
+ return;
388
+ }
360
389
  if (activeThreadId) {
361
390
  const threadScope = readKnownThreadScope(activeThreadId);
362
391
  if (threadScope === undefined) return;
@@ -378,6 +407,8 @@ export function useChatThreads(
378
407
  addOptimisticThread,
379
408
  autoCreate,
380
409
  readKnownThreadScope,
410
+ routeControlsActiveThread,
411
+ routeThreadId,
381
412
  storageKey,
382
413
  threads,
383
414
  ]);
@@ -484,6 +515,10 @@ export function useChatThreads(
484
515
  const loadedHasSavedId = Boolean(
485
516
  savedId && loadedThreads.some((t) => t.id === savedId),
486
517
  );
518
+ const savedIdCameFromRoute =
519
+ Boolean(savedId) &&
520
+ routeControlsActiveThread &&
521
+ routeThreadId === savedId;
487
522
 
488
523
  if (
489
524
  savedId &&
@@ -491,6 +526,11 @@ export function useChatThreads(
491
526
  !loadedHasSavedId
492
527
  ) {
493
528
  addOptimisticThread(savedId, scopeRef.current ?? null);
529
+ } else if (savedId && savedIdCameFromRoute && !loadedHasSavedId) {
530
+ // A deep link may point to a thread that is not in the current list
531
+ // response. Keep it route-owned so AssistantChat can restore it via
532
+ // /threads/:id instead of reclassifying it as a new empty tab.
533
+ setActiveThreadId(savedId);
494
534
  } else if (
495
535
  savedId &&
496
536
  !newlyCreatedRef.current.has(savedId) &&
@@ -526,7 +566,13 @@ export function useChatThreads(
526
566
  }
527
567
  setIsLoading(false);
528
568
  })();
529
- }, [fetchThreads, addOptimisticThread, autoCreate]);
569
+ }, [
570
+ fetchThreads,
571
+ addOptimisticThread,
572
+ autoCreate,
573
+ routeControlsActiveThread,
574
+ routeThreadId,
575
+ ]);
530
576
 
531
577
  const createThread = useCallback(
532
578
  (preferredId?: string): Promise<string | null> => {
@@ -543,6 +589,41 @@ export function useChatThreads(
543
589
  [addOptimisticThread],
544
590
  );
545
591
 
592
+ useEffect(() => {
593
+ if (!routeControlsActiveThread) return;
594
+ if (routeThreadId) {
595
+ if (activeThreadIdRef.current !== routeThreadId) {
596
+ setActiveThreadId(routeThreadId);
597
+ }
598
+ return;
599
+ }
600
+
601
+ const currentId = activeThreadIdRef.current;
602
+ const currentThread = currentId
603
+ ? threadsRef.current.find((thread) => thread.id === currentId)
604
+ : undefined;
605
+ const currentIsUnsavedNewThread =
606
+ currentId !== null &&
607
+ newlyCreatedRef.current.has(currentId) &&
608
+ (currentThread?.messageCount ?? 0) === 0;
609
+ if (currentIsUnsavedNewThread) return;
610
+
611
+ if (!autoCreate) {
612
+ if (currentId !== null) setActiveThreadId(null);
613
+ return;
614
+ }
615
+
616
+ const id = createLocalThreadId();
617
+ newlyCreatedRef.current.add(id);
618
+ addOptimisticThread(id, scopeRef.current ?? null);
619
+ setActiveThreadId(id);
620
+ }, [
621
+ addOptimisticThread,
622
+ autoCreate,
623
+ routeControlsActiveThread,
624
+ routeThreadId,
625
+ ]);
626
+
546
627
  // Drop a thread's scope so it becomes a general (cross-resource) chat.
547
628
  // This is the "Detach from <deck>" escape hatch in the UI. The PUT
548
629
  // also bumps the thread's updatedAt so it surfaces in the All Chats
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
 
5
5
  export default defineAction({
6
6
  description:
7
- "Navigate the Assets UI. Views (internal keys, with the surface they open): create, picker (the image Library browser), libraries (the Brand Kits list), library (a single Brand Kit), asset, generation-session, generation-run, extensions, audit, settings. Use libraryId, assetId, sessionId, runId, or extensionId where appropriate.",
7
+ "Navigate the Assets UI. Views (internal keys, with the surface they open): create, picker (the image Library browser), libraries (the Brand Kits list), library (a single Brand Kit), asset, generation-session, generation-run, extensions, audit, settings. Use threadId to open a specific create/chat thread; use libraryId, assetId, sessionId, runId, or extensionId where appropriate.",
8
8
  schema: z.object({
9
9
  view: z
10
10
  .enum([
@@ -25,6 +25,7 @@ export default defineAction({
25
25
  assetId: z.string().optional(),
26
26
  sessionId: z.string().optional(),
27
27
  runId: z.string().optional(),
28
+ threadId: z.string().optional(),
28
29
  presetId: z.string().optional(),
29
30
  mediaType: z.enum(["image", "video"]).optional(),
30
31
  query: z.string().optional(),
@@ -36,7 +36,8 @@ export function Layout({ children }: LayoutProps) {
36
36
  const location = useLocation();
37
37
  const navigate = useNavigate();
38
38
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
39
- const isCreateRoute = location.pathname === "/";
39
+ const isCreateRoute =
40
+ location.pathname === "/" || location.pathname.startsWith("/chat/");
40
41
  const chatHomeHandoffActive = useAgentChatHomeHandoff({
41
42
  storageKey: ASSETS_CHAT_STORAGE_KEY,
42
43
  activePath: location.pathname,
@@ -44,7 +45,7 @@ export function Layout({ children }: LayoutProps) {
44
45
  });
45
46
  useAgentChatHomeHandoffLinks({
46
47
  storageKey: ASSETS_CHAT_STORAGE_KEY,
47
- chatPath: "/",
48
+ isChatPath: (pathname) => pathname === "/" || pathname.startsWith("/chat/"),
48
49
  });
49
50
 
50
51
  useEffect(() => {
@@ -100,8 +100,24 @@ function persistedActiveThreadId() {
100
100
  }
101
101
  }
102
102
 
103
+ function threadIdFromPath(pathname: string) {
104
+ const match = pathname.match(/^\/chat\/([^/]+)/);
105
+ if (!match) return null;
106
+ try {
107
+ const value = decodeURIComponent(match[1]).trim();
108
+ return value || null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function chatThreadPath(threadId: string) {
115
+ return `/chat/${encodeURIComponent(threadId)}`;
116
+ }
117
+
103
118
  function AssetsChatsSection() {
104
119
  const navigate = useNavigate();
120
+ const location = useLocation();
105
121
  const {
106
122
  threads,
107
123
  activeThreadId,
@@ -159,7 +175,10 @@ function AssetsChatsSection() {
159
175
 
160
176
  function openThread(threadId: string, options?: { isNew?: boolean }) {
161
177
  switchThread(threadId);
162
- navigateWithAgentChatViewTransition(navigate, "/");
178
+ navigateWithAgentChatViewTransition(
179
+ navigate,
180
+ options?.isNew ? "/" : chatThreadPath(threadId),
181
+ );
163
182
  window.requestAnimationFrame(() => {
164
183
  window.dispatchEvent(
165
184
  new CustomEvent("agent-chat:open-thread", {
@@ -261,7 +280,10 @@ function AssetsChatsSection() {
261
280
  </div>
262
281
  <div className="grid gap-0.5">
263
282
  {visibleThreads.map((thread) => {
264
- const isActive = thread.id === activeThreadId;
283
+ const isActive =
284
+ thread.id ===
285
+ (threadIdFromPath(location.pathname) ??
286
+ (location.pathname === "/" ? null : activeThreadId));
265
287
  const isRenaming = thread.id === renamingThreadId;
266
288
  return (
267
289
  <div
@@ -367,7 +389,8 @@ function AssetsChatsSection() {
367
389
  export function Sidebar() {
368
390
  const location = useLocation();
369
391
  const navigate = useNavigate();
370
- const isCreateRoute = location.pathname === "/";
392
+ const isCreateRoute =
393
+ location.pathname === "/" || location.pathname.startsWith("/chat/");
371
394
  const { data: auditAdmin } = useActionQuery("is-audit-admin", {}, {
372
395
  refetchInterval: 30_000,
373
396
  } as any) as { data: { allowed?: boolean } | undefined };
@@ -453,7 +476,7 @@ export function Sidebar() {
453
476
  const Icon = item.icon;
454
477
  const isActive =
455
478
  item.href === "/"
456
- ? location.pathname === "/"
479
+ ? isCreateRoute
457
480
  : item.href === "/brand-kits"
458
481
  ? location.pathname === "/brand-kits" ||
459
482
  location.pathname.startsWith("/brand-kits/") ||
@@ -22,11 +22,18 @@ function optionalLibraryTab(params: URLSearchParams) {
22
22
  }
23
23
 
24
24
  function navigationFromPath(pathname: string, search = "") {
25
+ const params = new URLSearchParams(search);
26
+ const chat = pathname.match(/^\/chat\/([^/]+)/);
27
+ if (chat) {
28
+ return {
29
+ view: "create",
30
+ threadId: decodePathParam(chat[1]),
31
+ };
32
+ }
25
33
  // The "library" view is the brand-kit detail page (route /brand-kits/:id).
26
34
  // Keep the internal view key stable for the agent/MCP contract.
27
35
  const library = pathname.match(/^\/brand-kits\/([^/]+)/);
28
36
  if (library) {
29
- const params = new URLSearchParams(search);
30
37
  return {
31
38
  view: "library",
32
39
  libraryId: library[1],
@@ -37,10 +44,13 @@ function navigationFromPath(pathname: string, search = "") {
37
44
  if (asset) return { view: "asset", assetId: asset[1] };
38
45
  const image = pathname.match(/^\/image\/([^/]+)/);
39
46
  if (image) return { view: "asset", assetId: image[1] };
40
- if (pathname === "/") return { view: "create" };
47
+ if (pathname === "/") {
48
+ return {
49
+ view: "create",
50
+ };
51
+ }
41
52
  // The "picker" view is the image Library browser (route /library).
42
53
  if (pathname === "/library") {
43
- const params = new URLSearchParams(search);
44
54
  return {
45
55
  view: "picker",
46
56
  mediaType:
@@ -93,7 +103,12 @@ function pathFromCommand(command: any): string | null {
93
103
  }
94
104
  if (command.view === "audit") return "/audit";
95
105
  if (command.view === "settings") return "/settings";
96
- if (command.view === "create") return "/";
106
+ if (command.view === "create") {
107
+ if (typeof command.threadId === "string" && command.threadId.trim()) {
108
+ return `/chat/${encodeURIComponent(command.threadId.trim())}`;
109
+ }
110
+ return "/";
111
+ }
97
112
  if (command.view === "picker") {
98
113
  const params = new URLSearchParams();
99
114
  if (command.mediaType === "image" || command.mediaType === "video") {
@@ -131,7 +146,10 @@ export function useNavigationState() {
131
146
  navigationFromPath(pathname, search),
132
147
  getCommandPath: (command) => pathFromCommand(command),
133
148
  onNavigate: (_command, path) => {
134
- if (location.pathname === "/" && pathnameFromPath(path) !== "/") {
149
+ if (
150
+ isCreatePath(location.pathname) &&
151
+ !isCreatePath(pathnameFromPath(path))
152
+ ) {
135
153
  markAgentChatHomeHandoff(ASSETS_CHAT_STORAGE_KEY);
136
154
  }
137
155
  },
@@ -141,3 +159,15 @@ export function useNavigationState() {
141
159
  function pathnameFromPath(path: string): string {
142
160
  return path.split(/[?#]/, 1)[0] || "/";
143
161
  }
162
+
163
+ function decodePathParam(value: string): string {
164
+ try {
165
+ return decodeURIComponent(value);
166
+ } catch {
167
+ return value;
168
+ }
169
+ }
170
+
171
+ function isCreatePath(pathname: string): boolean {
172
+ return pathname === "/" || pathname.startsWith("/chat/");
173
+ }
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { useNavigate, useParams } from "react-router";
2
3
  import {
3
4
  AgentChatSurface,
4
5
  getBrowserTabId,
@@ -61,7 +62,13 @@ export function meta() {
61
62
  ];
62
63
  }
63
64
 
65
+ function chatThreadPath(threadId: string | null) {
66
+ return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/";
67
+ }
68
+
64
69
  export default function CreatePage() {
70
+ const { threadId } = useParams();
71
+ const navigate = useNavigate();
65
72
  const [imageModel, setImageModel] = useState<string>(DEFAULT_IMAGE_MODEL);
66
73
 
67
74
  useEffect(() => {
@@ -113,6 +120,11 @@ export default function CreatePage() {
113
120
  className="assets-create-chat-panel"
114
121
  defaultMode="chat"
115
122
  storageKey={ASSETS_CHAT_STORAGE_KEY}
123
+ threadUrlSync={{
124
+ routeThreadId: threadId ?? null,
125
+ getPath: chatThreadPath,
126
+ navigate,
127
+ }}
116
128
  browserTabId={getBrowserTabId()}
117
129
  imageModelMenu={{
118
130
  value: imageModel,
@@ -0,0 +1 @@
1
+ export { default, meta } from "./_index";
@@ -10,6 +10,7 @@
10
10
  * Options:
11
11
  * --view View name to navigate to
12
12
  * --path URL path to navigate to
13
+ * --threadId Chat thread ID to open on the chat route
13
14
  */
14
15
 
15
16
  import { defineAction } from "@agent-native/core/action";
@@ -22,6 +23,7 @@ export default defineAction({
22
23
  schema: z.object({
23
24
  view: z.string().optional().describe("View name to navigate to"),
24
25
  path: z.string().optional().describe("URL path to navigate to"),
26
+ threadId: z.string().optional().describe("Chat thread ID to open"),
25
27
  }),
26
28
  http: false,
27
29
  run: async (args) => {
@@ -31,6 +33,7 @@ export default defineAction({
31
33
  const nav: Record<string, string> = {};
32
34
  if (args.view) nav.view = args.view;
33
35
  if (args.path) nav.path = args.path;
36
+ if (args.threadId) nav.threadId = args.threadId;
34
37
  nav._writeId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
35
38
  await writeAppState("navigate", nav);
36
39
  return `Navigating to ${args.view || args.path}`;
@@ -35,6 +35,7 @@ const SIDEBAR_COLLAPSE_KEY = "chat.sidebar.collapsed";
35
35
  function routeOwnsToolbar(pathname: string): boolean {
36
36
  return (
37
37
  pathname === "/" ||
38
+ pathname.startsWith("/chat/") ||
38
39
  pathname === "/database" ||
39
40
  pathname.startsWith("/extensions")
40
41
  );
@@ -45,13 +46,17 @@ export function Layout({ children }: LayoutProps) {
45
46
  const navigate = useNavigate();
46
47
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
47
48
  const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
48
- const isChatRoute = location.pathname === "/";
49
+ const isChatRoute =
50
+ location.pathname === "/" || location.pathname.startsWith("/chat/");
49
51
  const chatHomeHandoffActive = useAgentChatHomeHandoff({
50
52
  storageKey: "chat",
51
53
  activePath: location.pathname,
52
54
  enabled: !isChatRoute,
53
55
  });
54
- useAgentChatHomeHandoffLinks({ storageKey: "chat", chatPath: "/" });
56
+ useAgentChatHomeHandoffLinks({
57
+ storageKey: "chat",
58
+ isChatPath: (pathname) => pathname === "/" || pathname.startsWith("/chat/"),
59
+ });
55
60
 
56
61
  useEffect(() => {
57
62
  setMobileSidebarOpen(false);
@@ -104,8 +104,24 @@ function persistedActiveThreadId() {
104
104
  }
105
105
  }
106
106
 
107
+ function threadIdFromPath(pathname: string) {
108
+ const match = pathname.match(/^\/chat\/([^/]+)/);
109
+ if (!match) return null;
110
+ try {
111
+ const value = decodeURIComponent(match[1]).trim();
112
+ return value || null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ function chatThreadPath(threadId: string) {
119
+ return `/chat/${encodeURIComponent(threadId)}`;
120
+ }
121
+
107
122
  function ChatThreadsSection() {
108
123
  const navigate = useNavigate();
124
+ const location = useLocation();
109
125
  const {
110
126
  threads,
111
127
  activeThreadId,
@@ -162,7 +178,10 @@ function ChatThreadsSection() {
162
178
 
163
179
  function openThread(threadId: string, options?: { isNew?: boolean }) {
164
180
  switchThread(threadId);
165
- navigateWithAgentChatViewTransition(navigate, "/");
181
+ navigateWithAgentChatViewTransition(
182
+ navigate,
183
+ options?.isNew ? "/" : chatThreadPath(threadId),
184
+ );
166
185
  window.requestAnimationFrame(() => {
167
186
  window.dispatchEvent(
168
187
  new CustomEvent("agent-chat:open-thread", {
@@ -244,7 +263,10 @@ function ChatThreadsSection() {
244
263
  </div>
245
264
  <div className="grid gap-0.5">
246
265
  {visibleThreads.map((thread) => {
247
- const isActive = thread.id === activeThreadId;
266
+ const isActive =
267
+ thread.id ===
268
+ (threadIdFromPath(location.pathname) ??
269
+ (location.pathname === "/" ? null : activeThreadId));
248
270
  const isRenaming = thread.id === renamingThreadId;
249
271
  return (
250
272
  <div
@@ -348,7 +370,8 @@ export function Sidebar({
348
370
  }: SidebarProps) {
349
371
  const location = useLocation();
350
372
  const navigate = useNavigate();
351
- const isChatRoute = location.pathname === "/";
373
+ const isChatRoute =
374
+ location.pathname === "/" || location.pathname.startsWith("/chat/");
352
375
  const ToggleIcon = collapsed
353
376
  ? IconLayoutSidebarLeftExpand
354
377
  : IconLayoutSidebarLeftCollapse;
@@ -440,7 +463,7 @@ export function Sidebar({
440
463
  const Icon = item.icon;
441
464
  const isActive =
442
465
  item.href === "/"
443
- ? location.pathname === "/"
466
+ ? isChatRoute
444
467
  : location.pathname.startsWith(item.href);
445
468
  const link = (
446
469
  <Link
@@ -10,6 +10,7 @@ import { TAB_ID } from "@/lib/tab-id";
10
10
  export interface NavigationState {
11
11
  view: string;
12
12
  path?: string;
13
+ threadId?: string;
13
14
  }
14
15
 
15
16
  export function useNavigationState() {
@@ -17,14 +18,21 @@ export function useNavigationState() {
17
18
  useAgentRouteState<NavigationState>({
18
19
  browserTabId: TAB_ID,
19
20
  requestSource: TAB_ID,
20
- getNavigationState: ({ pathname, search, hash }) => ({
21
- view: viewForPath(pathname),
22
- path: appPath(pathname),
23
- }),
21
+ getNavigationState: ({ pathname }) => {
22
+ const threadId = threadIdFromPath(pathname);
23
+ return {
24
+ view: viewForPath(pathname),
25
+ path: appPath(pathname),
26
+ ...(threadId ? { threadId } : {}),
27
+ };
28
+ },
24
29
  getCommandPath: (command) =>
25
- routerPath(command.path || pathForView(command.view)),
30
+ routerPath(command.path || pathForCommand(command)),
26
31
  onNavigate: (_command, path) => {
27
- if (location.pathname === "/" && pathnameFromPath(path) !== "/") {
32
+ if (
33
+ isChatPath(location.pathname) &&
34
+ !isChatPath(pathnameFromPath(path))
35
+ ) {
28
36
  markAgentChatHomeHandoff("chat");
29
37
  }
30
38
  },
@@ -35,7 +43,19 @@ function pathnameFromPath(path: string): string {
35
43
  return path.split(/[?#]/, 1)[0] || "/";
36
44
  }
37
45
 
46
+ function threadIdFromPath(pathname: string): string | null {
47
+ const match = pathname.match(/^\/chat\/([^/]+)/);
48
+ if (!match) return null;
49
+ try {
50
+ const value = decodeURIComponent(match[1]).trim();
51
+ return value || null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
38
57
  function viewForPath(pathname: string): string {
58
+ if (isChatPath(pathname)) return "chat";
39
59
  if (pathname.startsWith("/database")) return "database";
40
60
  if (pathname.startsWith("/extensions")) return "extensions";
41
61
  if (pathname.startsWith("/observability")) return "observability";
@@ -62,6 +82,14 @@ function pathForView(view?: string): string {
62
82
  }
63
83
  }
64
84
 
85
+ function pathForCommand(command: any): string {
86
+ const path = pathForView(command?.view);
87
+ if (path !== "/") return path;
88
+ const threadId =
89
+ typeof command?.threadId === "string" ? command.threadId.trim() : "";
90
+ return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/";
91
+ }
92
+
65
93
  function routerPath(path: string): string {
66
94
  const basePath = appBasePath();
67
95
  if (!basePath) return path;
@@ -71,3 +99,7 @@ function routerPath(path: string): string {
71
99
  }
72
100
  return path;
73
101
  }
102
+
103
+ function isChatPath(pathname: string): boolean {
104
+ return pathname === "/" || pathname.startsWith("/chat/");
105
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect } from "react";
2
+ import { useNavigate, useParams } from "react-router";
2
3
  import {
3
4
  AgentChatSurface,
4
5
  markAgentChatHomeHandoff,
@@ -25,7 +26,14 @@ export function meta() {
25
26
  ];
26
27
  }
27
28
 
29
+ function chatThreadPath(threadId: string | null) {
30
+ return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/";
31
+ }
32
+
28
33
  export default function ChatRoute() {
34
+ const { threadId } = useParams();
35
+ const navigate = useNavigate();
36
+
29
37
  useEffect(() => {
30
38
  function handleChatRunning(event: Event) {
31
39
  const detail = (event as CustomEvent).detail;
@@ -45,6 +53,11 @@ export default function ChatRoute() {
45
53
  className="h-full"
46
54
  defaultMode="chat"
47
55
  storageKey="chat"
56
+ threadUrlSync={{
57
+ routeThreadId: threadId ?? null,
58
+ getPath: chatThreadPath,
59
+ navigate,
60
+ }}
48
61
  browserTabId={TAB_ID}
49
62
  showHeader={false}
50
63
  showTabBar={false}
@@ -0,0 +1 @@
1
+ export { default, meta } from "./_index";
@@ -0,0 +1,26 @@
1
+ export { default } from "@agent-native/dispatch/routes/pages/chat";
2
+ import type { LoaderFunctionArgs } from "react-router";
3
+ import {
4
+ buildThreadLinkPreviewMeta,
5
+ type ThreadLinkPreview,
6
+ } from "@agent-native/dispatch/lib/thread-link-preview";
7
+
8
+ export async function loader({ params, request }: LoaderFunctionArgs) {
9
+ const threadId =
10
+ params.threadId ?? new URL(request.url).searchParams.get("thread");
11
+ const { loadThreadLinkPreview } =
12
+ await import("@agent-native/dispatch/server/lib/thread-link-preview");
13
+ return {
14
+ threadPreview: await loadThreadLinkPreview(threadId),
15
+ };
16
+ }
17
+
18
+ export function meta({
19
+ data,
20
+ }: {
21
+ data?: { threadPreview: ThreadLinkPreview | null };
22
+ }) {
23
+ return data?.threadPreview
24
+ ? buildThreadLinkPreviewMeta(data.threadPreview)
25
+ : [{ title: "Chat — Dispatch" }];
26
+ }
@@ -21,7 +21,7 @@
21
21
  * <AgentChatSurface mode="page" className="h-screen" />
22
22
  */
23
23
  import React from "react";
24
- import type { MultiTabAssistantChatHeaderProps } from "./MultiTabAssistantChat.js";
24
+ import type { MultiTabAssistantChatHeaderProps, MultiTabAssistantChatProps } from "./MultiTabAssistantChat.js";
25
25
  import type { AssistantChatProps } from "./AssistantChat.js";
26
26
  export declare function getAgentPanelChatTabGroups(tabs: MultiTabAssistantChatHeaderProps["tabs"], activeTabId: string): {
27
27
  activeTab: import("./MultiTabAssistantChat.js").ChatTab | undefined;
@@ -80,6 +80,8 @@ export interface AgentPanelProps extends Omit<AssistantChatProps, "onSwitchToCli
80
80
  scope?: import("./use-chat-threads.js").ChatThreadScope | null;
81
81
  /** Stable browser tab id used for tab-scoped app-state context. */
82
82
  browserTabId?: string;
83
+ /** Keep chat thread selection in URL state. */
84
+ threadUrlSync?: MultiTabAssistantChatProps["threadUrlSync"];
83
85
  /** Optional notice rendered below the main header while Chat mode is active. */
84
86
  chatNotice?: React.ReactNode;
85
87
  /** Show the chat thread tab row when the panel header is hidden. Default: true. */
@@ -150,12 +152,14 @@ export interface AgentSidebarProps {
150
152
  scope?: import("./use-chat-threads.js").ChatThreadScope | null;
151
153
  /** Stable browser tab id used for tab-scoped app-state context. */
152
154
  browserTabId?: string;
155
+ /** Keep chat thread selection in URL state. */
156
+ threadUrlSync?: MultiTabAssistantChatProps["threadUrlSync"];
153
157
  }
154
158
  /**
155
159
  * Wraps app content with a toggleable agent sidebar.
156
160
  * Use AgentToggleButton in your header to open/close it.
157
161
  */
158
- export declare function AgentSidebar({ children, emptyStateText, suggestions, dynamicSuggestions, defaultSidebarWidth, sidebarWidth, position, defaultOpen, animateMobile, chatViewTransition, storageKey, openOnChatRunning, onFullscreenRequest, scope, browserTabId, }: AgentSidebarProps): import("react/jsx-runtime").JSX.Element;
162
+ export declare function AgentSidebar({ children, emptyStateText, suggestions, dynamicSuggestions, defaultSidebarWidth, sidebarWidth, position, defaultOpen, animateMobile, chatViewTransition, storageKey, openOnChatRunning, onFullscreenRequest, scope, browserTabId, threadUrlSync, }: AgentSidebarProps): import("react/jsx-runtime").JSX.Element;
159
163
  /**
160
164
  * Focus the agent chat composer input.
161
165
  * Opens the sidebar if closed, then focuses the text input.