@agent-native/dispatch 0.40.37 → 0.40.38

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.
@@ -27,7 +27,6 @@ import {
27
27
  useAgentChatHomeHandoff,
28
28
  useAgentChatHomeHandoffLinks,
29
29
  useChatThreads,
30
- type ChatThreadSummary,
31
30
  } from "@agent-native/core/client/agent-chat";
32
31
  import { appBasePath, appPath } from "@agent-native/core/client/api-path";
33
32
  import {
@@ -55,6 +54,11 @@ import type {
55
54
  ChatFirstCopy,
56
55
  ChatFirstEmbedTarget,
57
56
  } from "@agent-native/toolkit/app/chat/chat-first/types";
57
+ import {
58
+ OwnershipChatHistoryRail,
59
+ ownDraftRows,
60
+ useOwnershipChatHistory,
61
+ } from "@agent-native/toolkit/app/chat/ownership-history";
58
62
  import { FeedbackButton } from "@agent-native/toolkit/app/feedback";
59
63
  import { InvitationBanner, OrgSwitcher } from "@agent-native/toolkit/app/org";
60
64
  import { RunsTray } from "@agent-native/toolkit/app/progress";
@@ -64,10 +68,6 @@ import {
64
68
  AppSidebarHeader,
65
69
  openCommandMenu,
66
70
  } from "@agent-native/toolkit/app/shared";
67
- import {
68
- ChatHistoryRail,
69
- type ChatHistoryItem,
70
- } from "@agent-native/toolkit/chat-history";
71
71
  import { writeClipboardText } from "@agent-native/toolkit/clipboard";
72
72
  import {
73
73
  IconApps,
@@ -143,7 +143,6 @@ import {
143
143
  } from "../deferred-chat-components.js";
144
144
  import { Button } from "../ui/button";
145
145
  import { Sheet, SheetContent, SheetDescription, SheetTitle } from "../ui/sheet";
146
- import { Skeleton } from "../ui/skeleton";
147
146
  import {
148
147
  Tooltip,
149
148
  TooltipContent,
@@ -588,23 +587,6 @@ function threadIdFromPath(pathname: string): string | null {
588
587
  }
589
588
  }
590
589
 
591
- export function formatThreadAge(updatedAt: number, now = Date.now()) {
592
- const diffMs = Math.max(0, now - updatedAt);
593
- const minutes = Math.floor(diffMs / 60_000);
594
- if (minutes < 1) return "now";
595
- if (minutes < 60) return `${minutes}m`;
596
- const hours = Math.floor(minutes / 60);
597
- if (hours < 24) return `${hours}h`;
598
- const days = Math.floor(hours / 24);
599
- if (days < 14) return `${days}d`;
600
- if (days < 365) return `${Math.floor(days / 7)}w`;
601
- return `${Math.floor(days / 365)}y`;
602
- }
603
-
604
- function threadTitle(thread: ChatThreadSummary, fallback: string) {
605
- return thread.title || thread.preview || fallback;
606
- }
607
-
608
590
  function threadSourceIcon(platform: string | undefined): ReactNode {
609
591
  const normalized = platform?.trim().toLowerCase();
610
592
  if (!normalized) return null;
@@ -627,14 +609,6 @@ function readChatHistoryIncludesExternal(): boolean {
627
609
  }
628
610
  }
629
611
 
630
- function threadUpdatedAt(thread: ChatThreadSummary) {
631
- return Number.isFinite(thread.updatedAt)
632
- ? thread.updatedAt
633
- : Number.isFinite(thread.createdAt)
634
- ? thread.createdAt
635
- : 0;
636
- }
637
-
638
612
  function DispatchChatsSection({
639
613
  onNavigate,
640
614
  showNewChat = false,
@@ -663,78 +637,74 @@ function DispatchChatsSection({
663
637
  const location = useLocation();
664
638
  const [includeExternal, setIncludeExternal] = useState(false);
665
639
  const [historyPreferenceReady, setHistoryPreferenceReady] = useState(false);
666
- const historyModeRef = useRef(includeExternal);
667
640
  const {
668
641
  threads,
669
642
  activeThreadId,
670
- isLoading: chatsLoading,
671
- threadsLoadError,
672
643
  createThread,
673
644
  switchThread,
674
645
  renameThread,
675
- refreshThreads,
646
+ pinThread,
647
+ archiveThread,
648
+ isNewThread,
676
649
  } = useChatThreads(undefined, "dispatch", undefined, {
677
650
  autoCreate: false,
678
651
  includeExternal,
679
652
  });
680
653
 
681
- const visibleThreads = useMemo(
682
- () =>
683
- threads
684
- .filter(
685
- (thread) => thread.messageCount > 0 || thread.id === activeThreadId,
686
- )
687
- .sort((a, b) => threadUpdatedAt(b) - threadUpdatedAt(a))
688
- .slice(0, 8),
689
- [activeThreadId, threads],
690
- );
691
654
  const localPathname = localDispatchPath(location.pathname);
692
655
  const displayedActiveThreadId =
693
656
  threadIdFromPath(localPathname) ??
694
657
  (localPathname === "/chat" ? null : activeThreadId);
695
- const chatItems: ChatHistoryItem[] = visibleThreads.map((thread) => {
696
- const title = threadTitle(thread, t("dispatch.sidebar.newChat"));
697
- const sourceIcon = threadSourceIcon(thread.source?.platform);
698
- const sourceLabel = thread.source?.platform
699
- ? thread.source.platform[0].toUpperCase() +
700
- thread.source.platform.slice(1)
701
- : null;
702
- return {
703
- id: thread.id,
704
- title: (
705
- <span
706
- className="flex min-w-0 items-center gap-1"
707
- title={sourceLabel ? `${sourceLabel}: ${title}` : title}
708
- >
709
- {sourceIcon ? (
710
- <span
711
- className="shrink-0 text-sidebar-foreground/55"
712
- aria-label={sourceLabel ?? "Connected source"}
713
- >
714
- {sourceIcon}
715
- </span>
716
- ) : null}
717
- <span className="truncate">{title}</span>
718
- </span>
719
- ),
720
- titleText: title,
721
- timestamp:
722
- thread.id === displayedActiveThreadId
723
- ? ""
724
- : formatThreadAge(threadUpdatedAt(thread)),
725
- };
658
+ const history = useOwnershipChatHistory({
659
+ getThreadUrl: (threadId) => appPath(chatThreadPath(threadId)),
660
+ includeExternal,
661
+ activeId: displayedActiveThreadId,
662
+ localMineRows: ownDraftRows({
663
+ threads,
664
+ isNewThread,
665
+ activeId: activeThreadId,
666
+ }),
667
+ mutations: { pin: pinThread, rename: renameThread, archive: archiveThread },
668
+ onMutationFailed: () =>
669
+ toast.error(t("dispatch.integrations.failedToSave")),
670
+ decorateItem: (item, row) => {
671
+ const source = "source" in row ? row.source : undefined;
672
+ const sourceLabel = source?.platform
673
+ ? source.platform[0].toUpperCase() + source.platform.slice(1)
674
+ : undefined;
675
+ const sourceIcon = threadSourceIcon(source?.platform);
676
+ return {
677
+ ...item,
678
+ title: (
679
+ <span
680
+ className="flex min-w-0 items-center gap-1"
681
+ title={
682
+ sourceLabel ? `${sourceLabel}: ${item.titleText}` : item.titleText
683
+ }
684
+ >
685
+ {sourceIcon ? (
686
+ <span
687
+ className="shrink-0 text-sidebar-foreground/55"
688
+ aria-label={sourceLabel}
689
+ >
690
+ {sourceIcon}
691
+ </span>
692
+ ) : null}
693
+ <span className="truncate">{item.title}</span>
694
+ </span>
695
+ ),
696
+ };
697
+ },
726
698
  });
727
- const chatHistoryError = threadsLoadError ? (
728
- <button
729
- type="button"
730
- onClick={() => refreshThreads()}
731
- className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
732
- >
733
- <span>Could not load chats</span>
734
- <span className="font-medium">Retry</span>
735
- </button>
736
- ) : undefined;
737
-
699
+ const listProps = {
700
+ ...history.listProps,
701
+ onDelete: async (threadId: string) => {
702
+ const archived = await history.listProps.onDelete?.(threadId);
703
+ if (archived && threadId === displayedActiveThreadId)
704
+ await handleNewChat();
705
+ return archived === true;
706
+ },
707
+ };
738
708
  useEffect(() => {
739
709
  setIncludeExternal(readChatHistoryIncludesExternal());
740
710
  setHistoryPreferenceReady(true);
@@ -748,30 +718,7 @@ function DispatchChatsSection({
748
718
  includeExternal ? "all" : "local",
749
719
  );
750
720
  } catch {} // coercion-ok: localStorage is optional browser persistence.
751
- if (historyModeRef.current !== includeExternal) {
752
- historyModeRef.current = includeExternal;
753
- refreshThreads();
754
- }
755
- }, [historyPreferenceReady, includeExternal, refreshThreads]);
756
-
757
- useEffect(() => {
758
- const refresh = () => refreshThreads();
759
- const handleRunning = (event: Event) => {
760
- const detail = (event as CustomEvent).detail as
761
- | { isRunning?: unknown }
762
- | undefined;
763
- if (detail?.isRunning === false) refreshThreads();
764
- };
765
-
766
- window.addEventListener("agent-chat:threads-updated", refresh);
767
- window.addEventListener("agentNative.chatRunning", handleRunning);
768
- window.addEventListener("focus", refresh);
769
- return () => {
770
- window.removeEventListener("agent-chat:threads-updated", refresh);
771
- window.removeEventListener("agentNative.chatRunning", handleRunning);
772
- window.removeEventListener("focus", refresh);
773
- };
774
- }, [refreshThreads]);
721
+ }, [historyPreferenceReady, includeExternal]);
775
722
 
776
723
  function openThread(threadId: string, options?: { isNew?: boolean }) {
777
724
  switchThread(threadId);
@@ -796,6 +743,36 @@ function DispatchChatsSection({
796
743
  if (threadId) openThread(threadId, { isNew: true });
797
744
  }
798
745
 
746
+ const sourceToggle = (
747
+ <Tooltip>
748
+ <TooltipTrigger asChild>
749
+ <button
750
+ type="button"
751
+ data-dispatch-chat-source-toggle
752
+ aria-pressed={includeExternal}
753
+ aria-label={
754
+ includeExternal
755
+ ? t("dispatch.sidebar.showLocalChats")
756
+ : t("dispatch.sidebar.showAllChats")
757
+ }
758
+ onClick={() => setIncludeExternal((current) => !current)}
759
+ className="flex size-6 items-center justify-center rounded-md text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
760
+ >
761
+ {includeExternal ? (
762
+ <IconWorld size={14} aria-hidden="true" />
763
+ ) : (
764
+ <IconDeviceDesktop size={14} aria-hidden="true" />
765
+ )}
766
+ </button>
767
+ </TooltipTrigger>
768
+ <TooltipContent side="right">
769
+ {includeExternal
770
+ ? t("dispatch.sidebar.showLocalChats")
771
+ : t("dispatch.sidebar.showAllChats")}
772
+ </TooltipContent>
773
+ </Tooltip>
774
+ );
775
+
799
776
  const collapsedChatFirst = collapsed && chatFirstMode;
800
777
 
801
778
  return (
@@ -822,80 +799,26 @@ function DispatchChatsSection({
822
799
  </nav>
823
800
  ) : null}
824
801
  {prelude}
825
- {!collapsedChatFirst && !chatFirstMode ? (
826
- <div className="flex justify-end px-2 pt-0.5">
827
- <Tooltip>
828
- <TooltipTrigger asChild>
829
- <button
830
- type="button"
831
- data-dispatch-chat-source-toggle
832
- aria-pressed={includeExternal}
833
- aria-label={
834
- includeExternal
835
- ? t("dispatch.sidebar.showLocalChats", {
836
- defaultValue: "Show local chats",
837
- })
838
- : t("dispatch.sidebar.showAllChats", {
839
- defaultValue: "Show all chats",
840
- })
841
- }
842
- onClick={() => setIncludeExternal((current) => !current)}
843
- className="flex size-6 items-center justify-center rounded-md text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
844
- >
845
- {includeExternal ? (
846
- <IconWorld size={14} aria-hidden="true" />
847
- ) : (
848
- <IconDeviceDesktop size={14} aria-hidden="true" />
849
- )}
850
- </button>
851
- </TooltipTrigger>
852
- <TooltipContent side="right">
853
- {includeExternal
854
- ? t("dispatch.sidebar.showLocalChats", {
855
- defaultValue: "Show local chats",
856
- })
857
- : t("dispatch.sidebar.showAllChats", {
858
- defaultValue: "Show all chats",
859
- })}
860
- </TooltipContent>
861
- </Tooltip>
862
- </div>
802
+ {!collapsedChatFirst ? (
803
+ <div className="flex justify-end px-2 pt-0.5">{sourceToggle}</div>
863
804
  ) : null}
864
- {!collapsedChatFirst &&
865
- !chatFirstMode &&
866
- chatsLoading &&
867
- visibleThreads.length === 0 &&
868
- Array.from({ length: 3 }).map((_, index) => (
869
- <div
870
- key={`chat-skeleton-${index}`}
871
- className="flex items-center gap-2 px-3 py-1"
872
- >
873
- <Skeleton className="size-3.5 shrink-0 rounded-sm" />
874
- <Skeleton className="h-3 w-3/4 rounded" />
875
- </div>
876
- ))}
877
805
  {!collapsedChatFirst &&
878
806
  chatFirstMode &&
879
- (chatsLoading || visibleThreads.length > 0) ? (
807
+ (history.loading ||
808
+ history.sections.some(
809
+ (section) => section.items.length > 0 || section.error,
810
+ )) ? (
880
811
  <ChatFirstChatHistory
881
- items={chatItems}
812
+ sections={history.sections}
813
+ {...history.listProps}
814
+ onDelete={listProps.onDelete}
815
+ onNewChat={() => void handleNewChat()}
816
+ showNewChat={false}
817
+ railLabels={history.railLabels}
882
818
  activeId={displayedActiveThreadId}
883
- loading={chatsLoading && visibleThreads.length === 0}
884
- loadingLabel={
885
- <div className="space-y-1 px-2 py-1">
886
- {[0, 1, 2].map((index) => (
887
- <div key={index} className="flex items-center gap-2 px-1 py-1">
888
- <Skeleton className="size-3.5 shrink-0 rounded-sm" />
889
- <Skeleton className="h-3 w-3/4 rounded" />
890
- </div>
891
- ))}
892
- </div>
893
- }
894
- label={t("dispatch.sidebar.chats", { defaultValue: "Chats" })}
895
- error={chatHistoryError}
819
+ label={t("dispatch.sidebar.chats")}
896
820
  onSelect={(threadId) => openThread(threadId)}
897
821
  renameMaxLength={160}
898
- onRename={(threadId, title) => void renameThread(threadId, title)}
899
822
  labels={{
900
823
  options: (item) =>
901
824
  t("dispatch.sidebar.chatOptions", {
@@ -906,6 +829,9 @@ function DispatchChatsSection({
906
829
  title: item.titleText ?? "",
907
830
  }),
908
831
  rename: t("dispatch.sidebar.renameChat"),
832
+ pin: t("agentChat.contextXray.pin"),
833
+ unpin: t("agentChat.contextXray.unpin"),
834
+ delete: t("extensions.delete"),
909
835
  }}
910
836
  renderAdditionalRowActions={(item, closeMenu) => (
911
837
  <>
@@ -918,14 +844,14 @@ function DispatchChatsSection({
918
844
  void writeClipboardText(item.id).then((copied) => {
919
845
  toast[copied ? "success" : "error"](
920
846
  copied
921
- ? "Session ID copied"
922
- : "Could not copy the session ID",
847
+ ? chatFirstCopy("sessionIdCopied")
848
+ : t("agentChat.recovery.copyFailed"),
923
849
  );
924
850
  });
925
851
  }}
926
852
  >
927
853
  <IconCopy size={13} strokeWidth={1.8} />
928
- <span>Copy session ID</span>
854
+ <span>{chatFirstCopy("copySessionId")}</span>
929
855
  </button>
930
856
  {!chatFirstEmbedded ? (
931
857
  <button
@@ -943,27 +869,24 @@ function DispatchChatsSection({
943
869
  }}
944
870
  >
945
871
  <IconEye size={13} strokeWidth={1.8} />
946
- <span>Watch and message session</span>
872
+ <span>{chatFirstCopy("watchSession")}</span>
947
873
  </button>
948
874
  ) : null}
949
875
  </>
950
876
  )}
951
877
  className="min-w-0 px-2"
952
878
  />
953
- ) : !collapsedChatFirst ? (
954
- <ChatHistoryRail
955
- items={chatItems}
879
+ ) : !collapsedChatFirst && !chatFirstMode ? (
880
+ <OwnershipChatHistoryRail
881
+ sections={history.sections}
882
+ listProps={listProps}
956
883
  activeId={displayedActiveThreadId}
957
884
  onSelect={(threadId) => openThread(threadId)}
958
885
  onNewChat={() => void handleNewChat()}
959
886
  railLabels={{
960
887
  newChat: t("dispatch.sidebar.newChat"),
961
- showMore: t("dispatch.sidebar.chats"),
962
- showLess: t("dispatch.sidebar.chats"),
963
888
  }}
964
- error={chatHistoryError}
965
889
  renameMaxLength={160}
966
- onRename={(threadId, title) => void renameThread(threadId, title)}
967
890
  labels={{
968
891
  options: (item) =>
969
892
  t("dispatch.sidebar.chatOptions", {
@@ -974,6 +897,9 @@ function DispatchChatsSection({
974
897
  title: item.titleText ?? "",
975
898
  }),
976
899
  rename: t("dispatch.sidebar.renameChat"),
900
+ pin: t("agentChat.contextXray.pin"),
901
+ unpin: t("agentChat.contextXray.unpin"),
902
+ delete: t("extensions.delete"),
977
903
  }}
978
904
  renderAdditionalRowActions={(item, closeMenu) => (
979
905
  <>
@@ -986,14 +912,14 @@ function DispatchChatsSection({
986
912
  void writeClipboardText(item.id).then((copied) => {
987
913
  toast[copied ? "success" : "error"](
988
914
  copied
989
- ? "Session ID copied"
990
- : "Could not copy the session ID",
915
+ ? chatFirstCopy("sessionIdCopied")
916
+ : t("agentChat.recovery.copyFailed"),
991
917
  );
992
918
  });
993
919
  }}
994
920
  >
995
921
  <IconCopy size={13} strokeWidth={1.8} />
996
- <span>Copy session ID</span>
922
+ <span>{chatFirstCopy("copySessionId")}</span>
997
923
  </button>
998
924
  {chatFirstMode && !chatFirstEmbedded ? (
999
925
  <button
@@ -1011,7 +937,7 @@ function DispatchChatsSection({
1011
937
  }}
1012
938
  >
1013
939
  <IconEye size={13} strokeWidth={1.8} />
1014
- <span>Watch and message session</span>
940
+ <span>{chatFirstCopy("watchSession")}</span>
1015
941
  </button>
1016
942
  ) : null}
1017
943
  </>
@@ -636,7 +636,7 @@ describe("listWorkspaceApps", () => {
636
636
  expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
637
637
  });
638
638
 
639
- it("signs the hosted registry request with the caller's organization secret", async () => {
639
+ it("signs the hosted registry request as the member with the deployment secret", async () => {
640
640
  const fetchMock = vi.fn(
641
641
  async () =>
642
642
  new Response(JSON.stringify([]), {
@@ -656,15 +656,36 @@ describe("listWorkspaceApps", () => {
656
656
  const authorization = fetchMock.mock.calls[0]?.[1]?.headers
657
657
  ?.Authorization as string;
658
658
  const token = authorization.slice("Bearer ".length);
659
- const payload = verifyJwtWithSecret(token, "workspace-org-secret");
659
+ const payload = verifyJwtWithSecret(token, "dispatch-deployment-secret");
660
660
  expect(payload).toMatchObject({
661
661
  sub: "dev@example.test",
662
662
  org_domain: "example.test",
663
663
  aud: "https://agent-workspace.builder.io",
664
664
  });
665
- expect(() =>
666
- verifyJwtWithSecret(token, "dispatch-deployment-secret"),
667
- ).toThrow();
665
+ expect(() => verifyJwtWithSecret(token, "workspace-org-secret")).toThrow();
666
+ });
667
+
668
+ it("reports a missing deployment secret instead of signing a member's request with the organization secret", async () => {
669
+ const fetchMock = vi.fn();
670
+ vi.stubGlobal("fetch", fetchMock);
671
+ vi.stubEnv("A2A_SECRET", "");
672
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
673
+ mocks.getOrgA2ASecret.mockResolvedValueOnce("workspace-org-secret");
674
+ stubManifest([
675
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
676
+ { id: "clips", name: "Clips", path: "/clips" },
677
+ ]);
678
+
679
+ await expect(
680
+ runWithRequestContext(
681
+ { userEmail: "dev@example.test", orgId: "org-1" },
682
+ () => listWorkspaceApps({ includeAgentCards: false }),
683
+ ),
684
+ ).rejects.toMatchObject({
685
+ statusCode: 503,
686
+ errorCode: "workspace_gateway_a2a_secret_missing",
687
+ });
688
+ expect(fetchMock).not.toHaveBeenCalled();
668
689
  });
669
690
 
670
691
  it("reports missing hosted gateway signing credentials instead of sending anonymously", async () => {
@@ -1935,10 +1935,14 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1935
1935
  );
1936
1936
  }
1937
1937
  }
1938
- const globalSecret = getGlobalA2ASecret();
1939
- const allowLocalUnauthenticatedRequest =
1940
- isLocalGateway && !orgSecret && !globalSecret;
1941
- if (!orgSecret && !globalSecret && !allowLocalUnauthenticatedRequest) {
1938
+ // The receiver drops the user from any user token that verifies only with an
1939
+ // organization secret, so a member's own request is signed with the
1940
+ // deployment secret to stay named.
1941
+ const delegatedMember =
1942
+ requestContext.a2aIdentityAssurance === "org-member";
1943
+ const signingSecret = delegatedMember ? orgSecret : getGlobalA2ASecret();
1944
+ const allowLocalUnauthenticatedRequest = isLocalGateway && !signingSecret;
1945
+ if (!signingSecret && !allowLocalUnauthenticatedRequest) {
1942
1946
  throw new WorkspaceAppsGatewayAuthorizationError(
1943
1947
  503,
1944
1948
  "workspace_gateway_a2a_secret_missing",
@@ -1950,10 +1954,10 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1950
1954
  const token = await signA2AToken(
1951
1955
  requestContext.userEmail,
1952
1956
  orgDomain,
1953
- orgSecret,
1957
+ delegatedMember ? orgSecret : undefined,
1954
1958
  {
1955
1959
  expiresIn: "1m",
1956
- preferGlobalSecret: false,
1960
+ preferGlobalSecret: !delegatedMember,
1957
1961
  audience: canonicalA2AAudience(
1958
1962
  baseUrl.toString(),
1959
1963
  getConfiguredAppBasePath() || undefined,
@@ -95,7 +95,7 @@ export const CURATED_COMMUNITY_APP_SOURCES: readonly CuratedCommunityAppSource[]
95
95
  },
96
96
  {
97
97
  path: "package.json",
98
- blobSha: "dbf733f4e0b751a91398233040e275ec32955a07",
98
+ blobSha: "b37b5659f115dd887f97611ff01e9861c004e0f8",
99
99
  },
100
100
  {
101
101
  path: "server/db/index.ts",