@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
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 1160
31
- - template files: 4005
31
+ - template files: 4008
@@ -1,5 +1,19 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.72.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 6605885: Add opt-in URL sync for durable chat threads and route chat-first templates (chat, assets, and Dispatch) through `/chat/:threadId` deep links.
8
+
9
+ ## 0.72.2
10
+
11
+ ### Patch Changes
12
+
13
+ - cc21a1c: Build the audit-redaction test fixture from concatenated parts so Netlify's
14
+ secret scanner no longer flags a literal `sk-` token pattern and blocks the
15
+ deploy.
16
+
3
17
  ## 0.72.1
4
18
 
5
19
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.72.1",
3
+ "version": "0.72.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -75,7 +75,10 @@ const MultiTabAssistantChatLazy = lazy(() =>
75
75
  default: m.MultiTabAssistantChat,
76
76
  })),
77
77
  );
78
- import type { MultiTabAssistantChatHeaderProps } from "./MultiTabAssistantChat.js";
78
+ import type {
79
+ MultiTabAssistantChatHeaderProps,
80
+ MultiTabAssistantChatProps,
81
+ } from "./MultiTabAssistantChat.js";
79
82
  import type { AssistantChatProps } from "./AssistantChat.js";
80
83
  import { assistantUiRecoverableRenderErrorKind } from "./assistant-ui-recovery.js";
81
84
  import { useDevMode } from "./use-dev-mode.js";
@@ -518,6 +521,8 @@ export interface AgentPanelProps extends Omit<
518
521
  scope?: import("./use-chat-threads.js").ChatThreadScope | null;
519
522
  /** Stable browser tab id used for tab-scoped app-state context. */
520
523
  browserTabId?: string;
524
+ /** Keep chat thread selection in URL state. */
525
+ threadUrlSync?: MultiTabAssistantChatProps["threadUrlSync"];
521
526
  /** Optional notice rendered below the main header while Chat mode is active. */
522
527
  chatNotice?: React.ReactNode;
523
528
  /** Show the chat thread tab row when the panel header is hidden. Default: true. */
@@ -636,6 +641,7 @@ function AgentPanelInner({
636
641
  restoreActiveThread = true,
637
642
  scope,
638
643
  browserTabId,
644
+ threadUrlSync,
639
645
  chatNotice,
640
646
  showTabBar = true,
641
647
  codeAccess,
@@ -1589,6 +1595,7 @@ function AgentPanelInner({
1589
1595
  restoreActiveThread={restoreActiveThread}
1590
1596
  scope={scope}
1591
1597
  browserTabId={browserTabId}
1598
+ threadUrlSync={threadUrlSync}
1592
1599
  />
1593
1600
  </Suspense>
1594
1601
  )}
@@ -2240,6 +2247,8 @@ export interface AgentSidebarProps {
2240
2247
  scope?: import("./use-chat-threads.js").ChatThreadScope | null;
2241
2248
  /** Stable browser tab id used for tab-scoped app-state context. */
2242
2249
  browserTabId?: string;
2250
+ /** Keep chat thread selection in URL state. */
2251
+ threadUrlSync?: MultiTabAssistantChatProps["threadUrlSync"];
2243
2252
  }
2244
2253
 
2245
2254
  /**
@@ -2262,6 +2271,7 @@ export function AgentSidebar({
2262
2271
  onFullscreenRequest,
2263
2272
  scope,
2264
2273
  browserTabId,
2274
+ threadUrlSync,
2265
2275
  }: AgentSidebarProps) {
2266
2276
  const initialWidth = defaultSidebarWidth ?? sidebarWidth ?? 380;
2267
2277
  const [open, setOpen] = useState(() =>
@@ -2730,6 +2740,7 @@ export function AgentSidebar({
2730
2740
  storageKey={storageKey}
2731
2741
  scope={scope}
2732
2742
  browserTabId={browserTabId}
2743
+ threadUrlSync={threadUrlSync}
2733
2744
  />
2734
2745
  </div>
2735
2746
  {showResizeHandle && isLeft && (
@@ -38,7 +38,7 @@ import {
38
38
  type ChatThreadScope,
39
39
  type ChatThreadSummary,
40
40
  } from "./use-chat-threads.js";
41
- import { agentNativePath } from "./api-path.js";
41
+ import { agentNativePath, appPath } from "./api-path.js";
42
42
  import { callAction } from "./use-action.js";
43
43
  import { RunStuckBanner } from "./RunStuckBanner.js";
44
44
  import { DEFAULT_MODEL } from "../agent/default-model.js";
@@ -781,6 +781,107 @@ function chatTabStatusFromAgentTeamStatus(
781
781
  }
782
782
 
783
783
  const STALE_THREAD_THRESHOLD_MS = 12 * 60 * 60 * 1000;
784
+ const DEFAULT_THREAD_URL_PARAM = "thread";
785
+ const THREAD_URL_CHANGED_EVENT = "agent-chat:url-thread-changed";
786
+ const hasOwn = Object.prototype.hasOwnProperty;
787
+
788
+ // The history patch is installed once and shared via a ref count so that
789
+ // multiple synced chats (or a remount) don't restore a stale `pushState`
790
+ // reference and silently drop a wrapper installed by another instance.
791
+ let historyPatchRefCount = 0;
792
+ let restoreHistoryPatch: (() => void) | null = null;
793
+
794
+ function installHistoryThreadUrlPatch(): () => void {
795
+ if (typeof window === "undefined") return () => {};
796
+ historyPatchRefCount += 1;
797
+ if (historyPatchRefCount === 1) {
798
+ const originalPushState = window.history.pushState;
799
+ const originalReplaceState = window.history.replaceState;
800
+ const dispatchUrlChange = () => {
801
+ window.dispatchEvent(new Event(THREAD_URL_CHANGED_EVENT));
802
+ };
803
+ window.history.pushState = function pushState(...args) {
804
+ const result = originalPushState.apply(this, args);
805
+ dispatchUrlChange();
806
+ return result;
807
+ };
808
+ window.history.replaceState = function replaceState(...args) {
809
+ const result = originalReplaceState.apply(this, args);
810
+ dispatchUrlChange();
811
+ return result;
812
+ };
813
+ restoreHistoryPatch = () => {
814
+ window.history.pushState = originalPushState;
815
+ window.history.replaceState = originalReplaceState;
816
+ };
817
+ }
818
+ return () => {
819
+ historyPatchRefCount = Math.max(0, historyPatchRefCount - 1);
820
+ if (historyPatchRefCount === 0) {
821
+ restoreHistoryPatch?.();
822
+ restoreHistoryPatch = null;
823
+ }
824
+ };
825
+ }
826
+
827
+ export interface ChatThreadUrlSyncOptions {
828
+ /** Query-string parameter used by the generic URL adapter. Default: `thread`. */
829
+ paramName?: string;
830
+ /**
831
+ * Route-owned thread id. Pass `null` for the create route and a string for
832
+ * thread routes like `/chat/:threadId`.
833
+ */
834
+ routeThreadId?: string | null;
835
+ /** Build the URL path for a thread id, or for create mode when id is null. */
836
+ getPath?: (threadId: string | null) => string;
837
+ /** Optional router navigation callback used with `getPath`. */
838
+ navigate?: (path: string, options?: { replace?: boolean }) => void;
839
+ }
840
+
841
+ function normalizeUrlThreadId(value: string | null | undefined): string | null {
842
+ if (typeof value !== "string") return null;
843
+ const trimmed = value.trim();
844
+ return trimmed || null;
845
+ }
846
+
847
+ function resolveThreadUrlSync(
848
+ value: MultiTabAssistantChatProps["threadUrlSync"],
849
+ ): {
850
+ enabled: boolean;
851
+ paramName: string;
852
+ routeThreadId?: string | null;
853
+ getPath?: (threadId: string | null) => string;
854
+ navigate?: (path: string, options?: { replace?: boolean }) => void;
855
+ } {
856
+ if (!value) return { enabled: false, paramName: DEFAULT_THREAD_URL_PARAM };
857
+ if (value === true) {
858
+ return { enabled: true, paramName: DEFAULT_THREAD_URL_PARAM };
859
+ }
860
+ return {
861
+ enabled: true,
862
+ paramName: value.paramName?.trim() || DEFAULT_THREAD_URL_PARAM,
863
+ ...(hasOwn.call(value, "routeThreadId")
864
+ ? { routeThreadId: normalizeUrlThreadId(value.routeThreadId) }
865
+ : {}),
866
+ ...(value.getPath ? { getPath: value.getPath } : {}),
867
+ ...(value.navigate ? { navigate: value.navigate } : {}),
868
+ };
869
+ }
870
+
871
+ function readUrlThreadId(paramName: string): string | null {
872
+ if (typeof window === "undefined") return null;
873
+ try {
874
+ const params = new URLSearchParams(window.location.search);
875
+ return normalizeUrlThreadId(
876
+ params.get(paramName) ??
877
+ (paramName === DEFAULT_THREAD_URL_PARAM
878
+ ? params.get("threadId")
879
+ : null),
880
+ );
881
+ } catch {
882
+ return null;
883
+ }
884
+ }
784
885
 
785
886
  function runToAgentTeamTabInfo(
786
887
  run: AgentTeamRunSummary,
@@ -845,6 +946,12 @@ export type MultiTabAssistantChatProps = Omit<
845
946
  restoreActiveThread?: boolean;
846
947
  /** Stable browser tab id used for tab-scoped app-state context. */
847
948
  browserTabId?: string;
949
+ /**
950
+ * Keep the active thread in URL state. `true` uses the generic `?thread=id`
951
+ * adapter; passing `routeThreadId` + `getPath` lets an app bind chats to
952
+ * route params such as `/chat/:threadId`.
953
+ */
954
+ threadUrlSync?: boolean | ChatThreadUrlSyncOptions;
848
955
  /**
849
956
  * Bind new chats to a resource (deck, design, dashboard, etc.). When set,
850
957
  * new chats automatically inherit this scope and scoped chats tuck away when
@@ -864,15 +971,114 @@ export function MultiTabAssistantChat({
864
971
  storageKey,
865
972
  restoreActiveThread = true,
866
973
  browserTabId,
974
+ threadUrlSync = false,
867
975
  scope = null,
868
976
  ...props
869
977
  }: MultiTabAssistantChatProps) {
978
+ const {
979
+ enabled: threadUrlSyncEnabled,
980
+ paramName: threadUrlParamName,
981
+ routeThreadId,
982
+ getPath: getThreadPath,
983
+ navigate: navigateThreadUrl,
984
+ } = resolveThreadUrlSync(threadUrlSync);
985
+ const threadRouteControlsActiveThread =
986
+ threadUrlSyncEnabled &&
987
+ threadUrlSync !== true &&
988
+ typeof threadUrlSync === "object" &&
989
+ hasOwn.call(threadUrlSync, "routeThreadId");
990
+ const [urlThreadId, setUrlThreadId] = useState<string | null>(() =>
991
+ threadUrlSyncEnabled
992
+ ? threadRouteControlsActiveThread
993
+ ? (routeThreadId ?? null)
994
+ : readUrlThreadId(threadUrlParamName)
995
+ : null,
996
+ );
997
+ const urlThreadIdRef = useRef(urlThreadId);
998
+ urlThreadIdRef.current = urlThreadId;
999
+
1000
+ useEffect(() => {
1001
+ if (!threadUrlSyncEnabled || threadRouteControlsActiveThread) return;
1002
+ const update = () => setUrlThreadId(readUrlThreadId(threadUrlParamName));
1003
+ const uninstallHistoryPatch = installHistoryThreadUrlPatch();
1004
+ update();
1005
+ window.addEventListener("popstate", update);
1006
+ window.addEventListener(THREAD_URL_CHANGED_EVENT, update);
1007
+ return () => {
1008
+ uninstallHistoryPatch();
1009
+ window.removeEventListener("popstate", update);
1010
+ window.removeEventListener(THREAD_URL_CHANGED_EVENT, update);
1011
+ };
1012
+ }, [
1013
+ threadRouteControlsActiveThread,
1014
+ threadUrlParamName,
1015
+ threadUrlSyncEnabled,
1016
+ ]);
1017
+
1018
+ useEffect(() => {
1019
+ if (!threadUrlSyncEnabled || !threadRouteControlsActiveThread) return;
1020
+ setUrlThreadId(routeThreadId ?? null);
1021
+ }, [routeThreadId, threadRouteControlsActiveThread, threadUrlSyncEnabled]);
1022
+
1023
+ const writeThreadUrl = useCallback(
1024
+ (threadId: string | null, options: { replace?: boolean } = {}): void => {
1025
+ if (!threadUrlSyncEnabled || typeof window === "undefined") return;
1026
+ try {
1027
+ const normalizedThreadId = normalizeUrlThreadId(threadId);
1028
+ let next: string;
1029
+ if (getThreadPath) {
1030
+ next = getThreadPath(normalizedThreadId);
1031
+ } else {
1032
+ const url = new URL(window.location.href);
1033
+ if (normalizedThreadId) {
1034
+ url.searchParams.set(threadUrlParamName, normalizedThreadId);
1035
+ } else {
1036
+ url.searchParams.delete(threadUrlParamName);
1037
+ }
1038
+ if (threadUrlParamName !== "threadId") {
1039
+ url.searchParams.delete("threadId");
1040
+ }
1041
+ next = `${url.pathname}${url.search}${url.hash}`;
1042
+ }
1043
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
1044
+ if (next === current) {
1045
+ setUrlThreadId(normalizedThreadId);
1046
+ return;
1047
+ }
1048
+ if (getThreadPath && navigateThreadUrl) {
1049
+ navigateThreadUrl(next, { replace: options.replace === true });
1050
+ setUrlThreadId(normalizedThreadId);
1051
+ return;
1052
+ }
1053
+ const method = options.replace ? "replaceState" : "pushState";
1054
+ // `getThreadPath` returns a router-local path (no app basename). When we
1055
+ // fall back to the raw History API instead of a router navigate, resolve
1056
+ // the basename so deep-link reloads work in mounted apps.
1057
+ const historyTarget = getThreadPath ? appPath(next) : next;
1058
+ window.history[method](window.history.state, "", historyTarget);
1059
+ setUrlThreadId(normalizedThreadId);
1060
+ window.dispatchEvent(new Event(THREAD_URL_CHANGED_EVENT));
1061
+ const popstate =
1062
+ typeof PopStateEvent === "function"
1063
+ ? new PopStateEvent("popstate", { state: window.history.state })
1064
+ : new Event("popstate");
1065
+ window.dispatchEvent(popstate);
1066
+ } catch {}
1067
+ },
1068
+ [
1069
+ getThreadPath,
1070
+ navigateThreadUrl,
1071
+ threadUrlParamName,
1072
+ threadUrlSyncEnabled,
1073
+ ],
1074
+ );
1075
+
870
1076
  const {
871
1077
  threads,
872
1078
  activeThreadId,
873
1079
  isLoading,
874
1080
  createThread,
875
- switchThread,
1081
+ switchThread: switchThreadState,
876
1082
  detachThread,
877
1083
  forkThread,
878
1084
  saveThreadData,
@@ -880,7 +1086,18 @@ export function MultiTabAssistantChat({
880
1086
  searchThreads,
881
1087
  refreshThreads,
882
1088
  isNewThread,
883
- } = useChatThreads(apiUrl, storageKey, scope, { restoreActiveThread });
1089
+ } = useChatThreads(apiUrl, storageKey, scope, {
1090
+ restoreActiveThread,
1091
+ routeThreadId: threadUrlSyncEnabled ? urlThreadId : undefined,
1092
+ });
1093
+
1094
+ const switchThread = useCallback(
1095
+ (threadId: string, options: { replace?: boolean } = {}) => {
1096
+ switchThreadState(threadId);
1097
+ writeThreadUrl(threadId, options);
1098
+ },
1099
+ [switchThreadState, writeThreadUrl],
1100
+ );
884
1101
 
885
1102
  // Namespace all localStorage keys by storageKey when provided (for per-app isolation in frame)
886
1103
  const keyPrefix = storageKey ? `:${storageKey}` : "";
@@ -1370,9 +1587,18 @@ export function MultiTabAssistantChat({
1370
1587
 
1371
1588
  // If active thread is stale, start fresh
1372
1589
  if (!parentMap[activeThreadId] && isStale(activeThreadId)) {
1373
- createThread();
1590
+ createThread().then((id) => {
1591
+ if (id) writeThreadUrl(null);
1592
+ });
1374
1593
  }
1375
- }, [activeThreadId, threads, parentMap, switchThread, createThread]);
1594
+ }, [
1595
+ activeThreadId,
1596
+ threads,
1597
+ parentMap,
1598
+ switchThread,
1599
+ createThread,
1600
+ writeThreadUrl,
1601
+ ]);
1376
1602
 
1377
1603
  // Ensure active thread is always in open tabs.
1378
1604
  // Use functional update to check inside the setter — avoids race with the
@@ -1412,10 +1638,11 @@ export function MultiTabAssistantChat({
1412
1638
  if (id) {
1413
1639
  newThreadIds.current.add(id);
1414
1640
  setOpenTabIds([id]);
1641
+ writeThreadUrl(null, { replace: true });
1415
1642
  }
1416
1643
  });
1417
1644
  }
1418
- }, [isLoading, openTabIds, activeThreadId, createThread]);
1645
+ }, [isLoading, openTabIds, activeThreadId, createThread, writeThreadUrl]);
1419
1646
 
1420
1647
  useEffect(() => {
1421
1648
  let stopped = false;
@@ -1700,9 +1927,12 @@ export function MultiTabAssistantChat({
1700
1927
  setOpenTabIds((prev) =>
1701
1928
  prev.includes(newId) ? prev : [...prev, newId],
1702
1929
  );
1930
+ if (!background) {
1931
+ writeThreadUrl(newId);
1932
+ }
1703
1933
  sendToTab(newId);
1704
1934
  if (background && previousTabId) {
1705
- switchThread(previousTabId);
1935
+ switchThreadState(previousTabId);
1706
1936
  }
1707
1937
  }
1708
1938
  });
@@ -1729,6 +1959,8 @@ export function MultiTabAssistantChat({
1729
1959
  removeContextInTab,
1730
1960
  setContextInTab,
1731
1961
  switchThread,
1962
+ switchThreadState,
1963
+ writeThreadUrl,
1732
1964
  ]);
1733
1965
 
1734
1966
  // Replay submits posted before this lazy panel's listener attached. Dedup in
@@ -1800,9 +2032,10 @@ export function MultiTabAssistantChat({
1800
2032
  const id = await createThread();
1801
2033
  if (id) {
1802
2034
  newThreadIds.current.add(id);
2035
+ writeThreadUrl(null);
1803
2036
  }
1804
2037
  return id;
1805
- }, [createThread]);
2038
+ }, [createThread, writeThreadUrl]);
1806
2039
 
1807
2040
  const cleanupClosedTab = useCallback((tabId: string) => {
1808
2041
  if (parentMapRef.current[tabId]) {
@@ -1842,6 +2075,7 @@ export function MultiTabAssistantChat({
1842
2075
  if (newId) {
1843
2076
  newThreadIds.current.add(newId);
1844
2077
  setOpenTabIds([newId]);
2078
+ writeThreadUrl(null);
1845
2079
  }
1846
2080
  });
1847
2081
  return prev; // Keep old tab until new one is ready
@@ -1855,7 +2089,7 @@ export function MultiTabAssistantChat({
1855
2089
  });
1856
2090
  cleanupClosedTab(tabId);
1857
2091
  },
1858
- [switchThread, createThread, cleanupClosedTab],
2092
+ [switchThread, createThread, cleanupClosedTab, writeThreadUrl],
1859
2093
  );
1860
2094
 
1861
2095
  const closeOtherTabs = useCallback(
@@ -1906,7 +2140,8 @@ export function MultiTabAssistantChat({
1906
2140
  if (id) {
1907
2141
  newThreadIds.current.add(id);
1908
2142
  setOpenTabIds([id]);
1909
- switchThread(id);
2143
+ switchThreadState(id);
2144
+ writeThreadUrl(null);
1910
2145
  dismissedSubAgentTabsRef.current.clear();
1911
2146
  // Clean up all old refs
1912
2147
  chatRefs.current.clear();
@@ -1917,7 +2152,7 @@ export function MultiTabAssistantChat({
1917
2152
  setSubAgentNames({});
1918
2153
  setSubAgentStatuses({});
1919
2154
  }
1920
- }, [createThread, switchThread]);
2155
+ }, [createThread, switchThreadState, writeThreadUrl]);
1921
2156
 
1922
2157
  // Keyboard shortcuts dispatched from AgentPanel based on the active mode
1923
2158
  useEffect(() => {
@@ -1955,6 +2190,14 @@ export function MultiTabAssistantChat({
1955
2190
 
1956
2191
  if (detail?.newThread === true) {
1957
2192
  newThreadIds.current.add(threadId);
2193
+ void createThread(threadId).then((createdId) => {
2194
+ if (!createdId) return;
2195
+ setOpenTabIds((prev) =>
2196
+ prev.includes(createdId) ? prev : [...prev, createdId],
2197
+ );
2198
+ writeThreadUrl(null);
2199
+ });
2200
+ return;
1958
2201
  }
1959
2202
  setOpenTabIds((prev) =>
1960
2203
  prev.includes(threadId) ? prev : [...prev, threadId],
@@ -1965,7 +2208,7 @@ export function MultiTabAssistantChat({
1965
2208
  window.addEventListener("agent-chat:open-thread", handleOpenThread);
1966
2209
  return () =>
1967
2210
  window.removeEventListener("agent-chat:open-thread", handleOpenThread);
1968
- }, [switchThread]);
2211
+ }, [createThread, switchThread, writeThreadUrl]);
1969
2212
 
1970
2213
  const clearActiveTab = useCallback(() => {
1971
2214
  const tabIdToClear = activeThreadIdRef.current;
@@ -2125,8 +2368,15 @@ export function MultiTabAssistantChat({
2125
2368
  },
2126
2369
  ) => {
2127
2370
  saveThreadData(threadId, data);
2371
+ if (
2372
+ data.messageCount > 0 &&
2373
+ threadId === activeThreadIdRef.current &&
2374
+ urlThreadIdRef.current !== threadId
2375
+ ) {
2376
+ writeThreadUrl(threadId);
2377
+ }
2128
2378
  },
2129
- [saveThreadData],
2379
+ [saveThreadData, writeThreadUrl],
2130
2380
  );
2131
2381
 
2132
2382
  // ─── Slash command handler ──────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { useLocation, useNavigate } from "react-router";
3
3
  import { appBasePath } from "./api-path.js";
4
4
  import {
@@ -21,6 +21,12 @@ export interface UseAgentChatHomeHandoffLinksOptions {
21
21
  storageKey?: string | null;
22
22
  /** Router-local path for the full-page chat route. Defaults to "/". */
23
23
  chatPath?: string;
24
+ /**
25
+ * Matches every router-local pathname owned by the full-page chat surface.
26
+ * Defaults to an exact match against `chatPath`. Pass this to cover deep
27
+ * links such as `/chat/:threadId`.
28
+ */
29
+ isChatPath?: (pathname: string) => boolean;
24
30
  /** Disable link interception without changing hook call order. */
25
31
  enabled?: boolean;
26
32
  }
@@ -120,13 +126,19 @@ export function useAgentChatHomeHandoff({
120
126
  export function useAgentChatHomeHandoffLinks({
121
127
  storageKey,
122
128
  chatPath = "/",
129
+ isChatPath,
123
130
  enabled = true,
124
131
  }: UseAgentChatHomeHandoffLinksOptions): void {
125
132
  const location = useLocation();
126
133
  const navigate = useNavigate();
134
+ const matchesChatPathRef = useRef<(pathname: string) => boolean>(() => false);
135
+ matchesChatPathRef.current =
136
+ isChatPath ?? ((pathname: string) => pathname === chatPath);
127
137
 
128
138
  useEffect(() => {
129
- if (!enabled || stripBasePath(location.pathname) !== chatPath) return;
139
+ const matchesChatPath = (pathname: string) =>
140
+ matchesChatPathRef.current(pathname);
141
+ if (!enabled || !matchesChatPath(stripBasePath(location.pathname))) return;
130
142
  if (typeof document === "undefined") return;
131
143
 
132
144
  function handleClick(event: MouseEvent) {
@@ -141,7 +153,7 @@ export function useAgentChatHomeHandoffLinks({
141
153
  const pathname = path ? pathnameFromLocalPath(path) : "";
142
154
  if (
143
155
  !path ||
144
- pathname === chatPath ||
156
+ matchesChatPath(pathname) ||
145
157
  isFrameworkOrApiPath(pathname) ||
146
158
  isStaticAssetPath(pathname)
147
159
  ) {
@@ -155,5 +167,5 @@ export function useAgentChatHomeHandoffLinks({
155
167
 
156
168
  document.addEventListener("click", handleClick, true);
157
169
  return () => document.removeEventListener("click", handleClick, true);
158
- }, [chatPath, enabled, location.pathname, navigate, storageKey]);
170
+ }, [enabled, location.pathname, navigate, storageKey]);
159
171
  }