@stigmer/react 3.4.1 → 3.5.1

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 (99) hide show
  1. package/agent/AgentDetailView.d.ts +14 -4
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +73 -11
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/agent/internal/agentToInput.d.ts +3 -0
  6. package/agent/internal/agentToInput.d.ts.map +1 -1
  7. package/agent/internal/agentToInput.js +21 -1
  8. package/agent/internal/agentToInput.js.map +1 -1
  9. package/api-key/ApiKeyListPanel.d.ts +8 -1
  10. package/api-key/ApiKeyListPanel.d.ts.map +1 -1
  11. package/api-key/ApiKeyListPanel.js +9 -7
  12. package/api-key/ApiKeyListPanel.js.map +1 -1
  13. package/dependency-graph/DependencyTreeNode.d.ts.map +1 -1
  14. package/dependency-graph/DependencyTreeNode.js +8 -0
  15. package/dependency-graph/DependencyTreeNode.js.map +1 -1
  16. package/dependency-graph/types.d.ts +20 -8
  17. package/dependency-graph/types.d.ts.map +1 -1
  18. package/dependency-graph/useDependencyGraph.d.ts.map +1 -1
  19. package/dependency-graph/useDependencyGraph.js +21 -1
  20. package/dependency-graph/useDependencyGraph.js.map +1 -1
  21. package/index.d.ts +2 -0
  22. package/index.d.ts.map +1 -1
  23. package/index.js +4 -0
  24. package/index.js.map +1 -1
  25. package/internal/scroll-area.d.ts +4 -0
  26. package/internal/scroll-area.d.ts.map +1 -0
  27. package/internal/scroll-area.js +15 -0
  28. package/internal/scroll-area.js.map +1 -0
  29. package/internal/tooltip.d.ts +7 -0
  30. package/internal/tooltip.d.ts.map +1 -0
  31. package/internal/tooltip.js +31 -0
  32. package/internal/tooltip.js.map +1 -0
  33. package/mcp-server/StdioSandboxNotice.d.ts +12 -11
  34. package/mcp-server/StdioSandboxNotice.d.ts.map +1 -1
  35. package/mcp-server/StdioSandboxNotice.js +13 -12
  36. package/mcp-server/StdioSandboxNotice.js.map +1 -1
  37. package/mcp-server/steps/IdentityTransportStep.d.ts.map +1 -1
  38. package/mcp-server/steps/IdentityTransportStep.js +1 -1
  39. package/mcp-server/steps/IdentityTransportStep.js.map +1 -1
  40. package/package.json +4 -4
  41. package/resource-creation/templates/mcp-server-templates.d.ts.map +1 -1
  42. package/resource-creation/templates/mcp-server-templates.js +17 -14
  43. package/resource-creation/templates/mcp-server-templates.js.map +1 -1
  44. package/session/useSessionExecutions.d.ts +14 -0
  45. package/session/useSessionExecutions.d.ts.map +1 -1
  46. package/session/useSessionExecutions.js +27 -1
  47. package/session/useSessionExecutions.js.map +1 -1
  48. package/sidebar/SettingsSidebar.d.ts +62 -0
  49. package/sidebar/SettingsSidebar.d.ts.map +1 -0
  50. package/sidebar/SettingsSidebar.js +60 -0
  51. package/sidebar/SettingsSidebar.js.map +1 -0
  52. package/sidebar/WorkspaceSidebar.d.ts +87 -0
  53. package/sidebar/WorkspaceSidebar.d.ts.map +1 -0
  54. package/sidebar/WorkspaceSidebar.js +95 -0
  55. package/sidebar/WorkspaceSidebar.js.map +1 -0
  56. package/sidebar/chrome.d.ts +48 -0
  57. package/sidebar/chrome.d.ts.map +1 -0
  58. package/sidebar/chrome.js +32 -0
  59. package/sidebar/chrome.js.map +1 -0
  60. package/sidebar/index.d.ts +6 -0
  61. package/sidebar/index.d.ts.map +1 -0
  62. package/sidebar/index.js +9 -0
  63. package/sidebar/index.js.map +1 -0
  64. package/sidebar/types.d.ts +54 -0
  65. package/sidebar/types.d.ts.map +1 -0
  66. package/sidebar/types.js +2 -0
  67. package/sidebar/types.js.map +1 -0
  68. package/src/agent/AgentDetailView.tsx +198 -11
  69. package/src/agent/__tests__/AgentDetailView.datastores.test.tsx +166 -0
  70. package/src/agent/internal/__tests__/agentToInput.test.ts +154 -0
  71. package/src/agent/internal/agentToInput.ts +25 -7
  72. package/src/api-key/ApiKeyListPanel.tsx +17 -4
  73. package/src/dependency-graph/DependencyTreeNode.tsx +8 -0
  74. package/src/dependency-graph/__tests__/useDependencyGraph.test.ts +108 -0
  75. package/src/dependency-graph/types.ts +20 -8
  76. package/src/dependency-graph/useDependencyGraph.ts +23 -1
  77. package/src/index.ts +13 -0
  78. package/src/internal/scroll-area.tsx +37 -0
  79. package/src/internal/tooltip.tsx +66 -0
  80. package/src/mcp-server/StdioSandboxNotice.tsx +16 -14
  81. package/src/mcp-server/__tests__/StdioSandboxNotice.test.tsx +3 -2
  82. package/src/mcp-server/steps/IdentityTransportStep.tsx +7 -0
  83. package/src/resource-creation/templates/mcp-server-templates.ts +17 -14
  84. package/src/session/__tests__/useSessionExecutions.test.ts +63 -0
  85. package/src/session/useSessionExecutions.ts +27 -1
  86. package/src/sidebar/SettingsSidebar.tsx +173 -0
  87. package/src/sidebar/WorkspaceSidebar.tsx +386 -0
  88. package/src/sidebar/__tests__/SettingsSidebar.test.tsx +107 -0
  89. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +236 -0
  90. package/src/sidebar/chrome.tsx +129 -0
  91. package/src/sidebar/index.ts +16 -0
  92. package/src/sidebar/types.ts +55 -0
  93. package/src/test/__tests__/samples.test.ts +2 -0
  94. package/src/test/samples.ts +48 -0
  95. package/styles.css +1 -1
  96. package/test/samples.d.ts +21 -0
  97. package/test/samples.d.ts.map +1 -1
  98. package/test/samples.js +32 -0
  99. package/test/samples.js.map +1 -1
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import { timestampDate } from "@bufbuild/protobuf/wkt";
6
6
  import type {
7
+ DatastoreUsage,
7
8
  McpServerUsage,
8
9
  SubAgent,
9
10
  } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
@@ -14,7 +15,9 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
14
15
  import { useAgent } from "./useAgent.js";
15
16
  import { useUpdateAgent } from "./useUpdateAgent.js";
16
17
  import { agentToInput } from "./internal/agentToInput.js";
18
+ import { toError } from "../internal/toError.js";
17
19
  import { ErrorMessage } from "../error/ErrorMessage.js";
20
+ import { DatastoreIcon } from "../datastore/DatastoreDetailView.js";
18
21
  import { VisibilityBadge } from "../library/VisibilitySelector.js";
19
22
  import { useManageAccess } from "../access/useManageAccess.js";
20
23
  import { AgentShareList } from "../sharing/AgentShareList.js";
@@ -58,6 +61,13 @@ export interface AgentDetailViewProps {
58
61
  * Provides `org` and `slug` of the referenced skill.
59
62
  */
60
63
  readonly onSkillClick?: (ref: { org: string; slug: string }) => void;
64
+ /**
65
+ * Called when a datastore reference is clicked.
66
+ * Provides `org` and `slug` of the referenced datastore so the
67
+ * consumer can wire navigation. When the reference has no explicit
68
+ * org, the agent's own org is used as fallback.
69
+ */
70
+ readonly onDatastoreClick?: (ref: { org: string; slug: string }) => void;
61
71
  /**
62
72
  * Called once when the agent resource has been fetched successfully.
63
73
  * Provides the resource display name for use cases like breadcrumbs,
@@ -176,9 +186,9 @@ export interface AgentDetailViewProps {
176
186
  * Fetches the agent via {@link useAgent} internally and renders its
177
187
  * full configuration inside a {@link ResourceDetailShell}: a
178
188
  * standardized header with action bar, followed by structured content
179
- * sections (instructions, MCP server usages, skills, sub-agents, and
180
- * environment variables). Sections with no data are omitted entirely
181
- * — reducing visual noise per Nielsen heuristic #8.
189
+ * sections (instructions, MCP server usages, skills, datastores,
190
+ * sub-agents, and environment variables). Sections with no data are
191
+ * omitted entirely — reducing visual noise per Nielsen heuristic #8.
182
192
  *
183
193
  * The action bar transforms this from a read-only view into an
184
194
  * operational hub. Actions are provided by the consumer via
@@ -215,6 +225,7 @@ export function AgentDetailView({
215
225
  slug,
216
226
  onMcpServerClick,
217
227
  onSkillClick,
228
+ onDatastoreClick,
218
229
  onResourceLoad,
219
230
  primaryAction,
220
231
  actions,
@@ -236,12 +247,22 @@ export function AgentDetailView({
236
247
  const { agent, isLoading, error, refetch } = useAgent(org, slug);
237
248
  const { update, isUpdating } = useUpdateAgent();
238
249
 
250
+ // Last failed inline save, attributed to the field that was edited so
251
+ // only that section shows the message. The backend's message is the
252
+ // UX (DD-006): e.g. the datastore exposure guard refuses public +
253
+ // datastore_usages with an actionable FAILED_PRECONDITION message.
254
+ const [saveError, setSaveError] = useState<{
255
+ field: string;
256
+ message: string;
257
+ } | null>(null);
258
+
239
259
  const saveField = useCallback(
240
260
  async <K extends keyof import("@stigmer/sdk").AgentInput>(
241
261
  field: K,
242
262
  value: import("@stigmer/sdk").AgentInput[K],
243
263
  ): Promise<boolean> => {
244
264
  if (!agent) return false;
265
+ setSaveError(null);
245
266
  const input = agentToInput(agent);
246
267
  (input as unknown as Record<string, unknown>)[field] = value;
247
268
  try {
@@ -249,13 +270,16 @@ export function AgentDetailView({
249
270
  onResourceUpdated?.(updated);
250
271
  refetch();
251
272
  return true;
252
- } catch {
273
+ } catch (err) {
274
+ setSaveError({ field, message: toError(err).message });
253
275
  return false;
254
276
  }
255
277
  },
256
278
  [agent, update, onResourceUpdated, refetch],
257
279
  );
258
280
 
281
+ const clearSaveError = useCallback(() => setSaveError(null), []);
282
+
259
283
  const { tree, isEmpty: noDeps } = useDependencyGraph({
260
284
  agentName: agent?.metadata?.name || agent?.metadata?.slug || slug,
261
285
  agentOrg: agent?.metadata?.org || org,
@@ -290,9 +314,11 @@ export function AgentDetailView({
290
314
  onMcpServerClick?.(node.ref);
291
315
  } else if (node.kind === "skill") {
292
316
  onSkillClick?.(node.ref);
317
+ } else if (node.kind === "datastore") {
318
+ onDatastoreClick?.(node.ref);
293
319
  }
294
320
  },
295
- [onMcpServerClick, onSkillClick],
321
+ [onMcpServerClick, onSkillClick, onDatastoreClick],
296
322
  );
297
323
 
298
324
  const onResourceLoadRef = useRef(onResourceLoad);
@@ -430,9 +456,12 @@ export function AgentDetailView({
430
456
  description={spec?.description}
431
457
  onMcpServerClick={onMcpServerClick}
432
458
  onSkillClick={onSkillClick}
459
+ onDatastoreClick={onDatastoreClick}
433
460
  editable={editable}
434
461
  isSaving={isUpdating}
435
462
  saveField={saveField}
463
+ saveError={saveError}
464
+ clearSaveError={clearSaveError}
436
465
  />
437
466
  );
438
467
  }
@@ -467,22 +496,33 @@ function AgentOverview({
467
496
  description,
468
497
  onMcpServerClick,
469
498
  onSkillClick,
499
+ onDatastoreClick,
470
500
  editable,
471
501
  isSaving,
472
502
  saveField,
503
+ saveError,
504
+ clearSaveError,
473
505
  }: {
474
506
  readonly spec: NonNullable<ReturnType<typeof useAgent>["agent"]>["spec"];
475
507
  readonly agentOrg: string;
476
508
  readonly description?: string;
477
509
  readonly onMcpServerClick?: (ref: { org: string; slug: string }) => void;
478
510
  readonly onSkillClick?: (ref: { org: string; slug: string }) => void;
511
+ readonly onDatastoreClick?: (ref: { org: string; slug: string }) => void;
479
512
  readonly editable?: boolean;
480
513
  readonly isSaving?: boolean;
481
514
  readonly saveField?: <K extends keyof import("@stigmer/sdk").AgentInput>(
482
515
  field: K,
483
516
  value: import("@stigmer/sdk").AgentInput[K],
484
517
  ) => Promise<boolean>;
518
+ readonly saveError?: { field: string; message: string } | null;
519
+ readonly clearSaveError?: () => void;
485
520
  }) {
521
+ // The failed save's message, shown only under the section that owns
522
+ // the field — a rejection of a datastore edit must not appear under
523
+ // the skills editor.
524
+ const errorFor = (field: keyof import("@stigmer/sdk").AgentInput) =>
525
+ saveError?.field === field ? saveError.message : undefined;
486
526
  const handleInstructionsSave = useCallback(
487
527
  async (v: string) => saveField?.("instructions", v || undefined) ?? false,
488
528
  [saveField],
@@ -508,6 +548,15 @@ function AgentOverview({
508
548
  [saveField],
509
549
  );
510
550
 
551
+ const handleDatastoresSave = useCallback(
552
+ async (refs: ResourceRefRow[]) =>
553
+ saveField?.(
554
+ "datastoreUsages",
555
+ refs.map((r) => ({ datastoreRef: { org: r.org, slug: r.slug } })),
556
+ ) ?? false,
557
+ [saveField],
558
+ );
559
+
511
560
  const handleEnvSave = useCallback(
512
561
  async (rows: KeyValueRow[]) => {
513
562
  const env: Record<string, { isSecret?: boolean; description?: string; optional?: boolean }> = {};
@@ -551,6 +600,19 @@ function AgentOverview({
551
600
  [spec?.skillRefs, agentOrg],
552
601
  );
553
602
 
603
+ const datastoreRefRows: ResourceRefRow[] = useMemo(
604
+ () =>
605
+ (spec?.datastoreUsages ?? []).map((usage) => ({
606
+ org: usage.datastoreRef?.org || agentOrg,
607
+ slug: usage.datastoreRef?.slug ?? "",
608
+ label:
609
+ usage.datastoreRef?.org && usage.datastoreRef.org !== agentOrg
610
+ ? `${usage.datastoreRef.org}/${usage.datastoreRef.slug}`
611
+ : usage.datastoreRef?.slug ?? "",
612
+ })),
613
+ [spec?.datastoreUsages, agentOrg],
614
+ );
615
+
554
616
  const envRows: KeyValueRow[] = useMemo(
555
617
  () =>
556
618
  Object.entries(spec?.env ?? {})
@@ -569,13 +631,46 @@ function AgentOverview({
569
631
  const showInstructions = editable || !!spec?.instructions;
570
632
  const showMcpServers = editable || (spec && spec.mcpServerUsages.length > 0);
571
633
  const showSkills = editable || (spec && spec.skillRefs.length > 0);
634
+ const showDatastores = editable || (spec && spec.datastoreUsages.length > 0);
572
635
  const showSubAgents = editable || (spec && spec.subAgents.length > 0);
573
636
  const showEnv = editable || (spec?.env && Object.keys(spec.env).length > 0);
574
637
 
575
638
  const [mcpEditing, setMcpEditing] = useState(false);
576
639
  const [skillsEditing, setSkillsEditing] = useState(false);
640
+ const [datastoresEditing, setDatastoresEditing] = useState(false);
577
641
  const [envEditing, setEnvEditing] = useState(false);
578
642
 
643
+ // Entering edit mode discards the previous attempt's error — the
644
+ // message describes a stale draft, not the one being composed.
645
+ const handleMcpEditingChange = useCallback(
646
+ (editing: boolean) => {
647
+ if (editing) clearSaveError?.();
648
+ setMcpEditing(editing);
649
+ },
650
+ [clearSaveError],
651
+ );
652
+ const handleSkillsEditingChange = useCallback(
653
+ (editing: boolean) => {
654
+ if (editing) clearSaveError?.();
655
+ setSkillsEditing(editing);
656
+ },
657
+ [clearSaveError],
658
+ );
659
+ const handleDatastoresEditingChange = useCallback(
660
+ (editing: boolean) => {
661
+ if (editing) clearSaveError?.();
662
+ setDatastoresEditing(editing);
663
+ },
664
+ [clearSaveError],
665
+ );
666
+ const handleEnvEditingChange = useCallback(
667
+ (editing: boolean) => {
668
+ if (editing) clearSaveError?.();
669
+ setEnvEditing(editing);
670
+ },
671
+ [clearSaveError],
672
+ );
673
+
579
674
  return (
580
675
  <div className="flex flex-col gap-6">
581
676
  {showDescription && (
@@ -586,6 +681,7 @@ function AgentOverview({
586
681
  value={spec?.description || ""}
587
682
  onSave={(v) => saveField?.("description", v || undefined) ?? Promise.resolve(false)}
588
683
  isSaving={isSaving}
684
+ error={errorFor("description")}
589
685
  placeholder="Add a description"
590
686
  minRows={2}
591
687
  />
@@ -608,6 +704,7 @@ function AgentOverview({
608
704
  value={spec?.instructions ?? ""}
609
705
  onSave={handleInstructionsSave}
610
706
  isSaving={isSaving}
707
+ error={errorFor("instructions")}
611
708
  placeholder="Add instructions for the agent"
612
709
  minRows={4}
613
710
  />
@@ -619,14 +716,15 @@ function AgentOverview({
619
716
  )}
620
717
 
621
718
  {showMcpServers && (
622
- <Section title="MCP Servers" count={spec?.mcpServerUsages.length} onEdit={editable ? () => setMcpEditing((v) => !v) : undefined}>
719
+ <Section title="MCP Servers" count={spec?.mcpServerUsages.length} onEdit={editable ? () => handleMcpEditingChange(!mcpEditing) : undefined}>
623
720
  {editable ? (
624
721
  <InlineEditResourceList
625
722
  value={mcpRefRows}
626
723
  onSave={handleMcpServersSave}
627
724
  isSaving={isSaving}
725
+ error={errorFor("mcpServerUsages")}
628
726
  editing={mcpEditing}
629
- onEditingChange={setMcpEditing}
727
+ onEditingChange={handleMcpEditingChange}
630
728
  onItemClick={onMcpServerClick ? (ref) => onMcpServerClick({ org: ref.org, slug: ref.slug }) : undefined}
631
729
  itemIcon={<McpServerIcon className="size-4" />}
632
730
  resourceLabel="MCP server"
@@ -643,14 +741,15 @@ function AgentOverview({
643
741
  )}
644
742
 
645
743
  {showSkills && (
646
- <Section title="Skills" count={spec?.skillRefs.length} onEdit={editable ? () => setSkillsEditing((v) => !v) : undefined}>
744
+ <Section title="Skills" count={spec?.skillRefs.length} onEdit={editable ? () => handleSkillsEditingChange(!skillsEditing) : undefined}>
647
745
  {editable ? (
648
746
  <InlineEditResourceList
649
747
  value={skillRefRows}
650
748
  onSave={handleSkillsSave}
651
749
  isSaving={isSaving}
750
+ error={errorFor("skillRefs")}
652
751
  editing={skillsEditing}
653
- onEditingChange={setSkillsEditing}
752
+ onEditingChange={handleSkillsEditingChange}
654
753
  onItemClick={onSkillClick ? (ref) => onSkillClick({ org: ref.org, slug: ref.slug }) : undefined}
655
754
  itemIcon={<SkillIcon className="size-4" />}
656
755
  resourceLabel="skill"
@@ -666,24 +765,51 @@ function AgentOverview({
666
765
  </Section>
667
766
  )}
668
767
 
768
+ {showDatastores && (
769
+ <Section title="Datastores" count={spec?.datastoreUsages.length} onEdit={editable ? () => handleDatastoresEditingChange(!datastoresEditing) : undefined}>
770
+ {editable ? (
771
+ <InlineEditResourceList
772
+ value={datastoreRefRows}
773
+ onSave={handleDatastoresSave}
774
+ isSaving={isSaving}
775
+ error={errorFor("datastoreUsages")}
776
+ editing={datastoresEditing}
777
+ onEditingChange={handleDatastoresEditingChange}
778
+ onItemClick={onDatastoreClick ? (ref) => onDatastoreClick({ org: ref.org, slug: ref.slug }) : undefined}
779
+ itemIcon={<DatastoreIcon className="size-4" />}
780
+ resourceLabel="datastore"
781
+ defaultOrg={agentOrg}
782
+ />
783
+ ) : (
784
+ <DatastoresContent
785
+ usages={spec?.datastoreUsages ?? []}
786
+ defaultOrg={agentOrg}
787
+ onDatastoreClick={onDatastoreClick}
788
+ />
789
+ )}
790
+ </Section>
791
+ )}
792
+
669
793
  {showSubAgents && (
670
794
  <SubAgentsSection
671
795
  subAgents={spec?.subAgents ?? []}
672
796
  editable={editable}
673
797
  isSaving={isSaving}
798
+ error={errorFor("subAgents")}
674
799
  onSave={(subs) => saveField?.("subAgents", subs.length > 0 ? subs : undefined) ?? Promise.resolve(false)}
675
800
  />
676
801
  )}
677
802
 
678
803
  {showEnv && (
679
- <Section title="Environment Variables" count={Object.keys(spec?.env ?? {}).length} onEdit={editable ? () => setEnvEditing((v) => !v) : undefined}>
804
+ <Section title="Environment Variables" count={Object.keys(spec?.env ?? {}).length} onEdit={editable ? () => handleEnvEditingChange(!envEditing) : undefined}>
680
805
  {editable ? (
681
806
  <InlineEditKeyValue
682
807
  value={envRows}
683
808
  onSave={handleEnvSave}
684
809
  isSaving={isSaving}
810
+ error={errorFor("env")}
685
811
  editing={envEditing}
686
- onEditingChange={setEnvEditing}
812
+ onEditingChange={handleEnvEditingChange}
687
813
  showSecretToggle
688
814
  showOptionalToggle
689
815
  showDescription
@@ -860,6 +986,59 @@ function SkillsContent({
860
986
  );
861
987
  }
862
988
 
989
+ function DatastoresContent({
990
+ usages,
991
+ defaultOrg,
992
+ onDatastoreClick,
993
+ }: {
994
+ readonly usages: readonly DatastoreUsage[];
995
+ readonly defaultOrg: string;
996
+ readonly onDatastoreClick?: (ref: { org: string; slug: string }) => void;
997
+ }) {
998
+ return (
999
+ <div className="flex flex-col">
1000
+ {usages.map((usage, index) => {
1001
+ const ref = usage.datastoreRef;
1002
+ if (!ref) return null;
1003
+
1004
+ const refOrg = ref.org || defaultOrg;
1005
+ const label =
1006
+ ref.org && ref.org !== defaultOrg
1007
+ ? `${ref.org}/${ref.slug}`
1008
+ : ref.slug;
1009
+
1010
+ const row = (
1011
+ <div className="flex items-center gap-3">
1012
+ <DatastoreIcon className="size-4 shrink-0 text-muted-foreground" />
1013
+ <span className="text-sm font-medium text-foreground">
1014
+ {label}
1015
+ </span>
1016
+ </div>
1017
+ );
1018
+
1019
+ return onDatastoreClick ? (
1020
+ <button
1021
+ key={ref.slug || index}
1022
+ type="button"
1023
+ onClick={() => onDatastoreClick({ org: refOrg, slug: ref.slug })}
1024
+ className={cn(
1025
+ "w-full rounded-md px-3 py-2 text-left transition-colors",
1026
+ "hover:bg-accent-hover",
1027
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
1028
+ )}
1029
+ >
1030
+ {row}
1031
+ </button>
1032
+ ) : (
1033
+ <div key={ref.slug || index} className="px-3 py-2">
1034
+ {row}
1035
+ </div>
1036
+ );
1037
+ })}
1038
+ </div>
1039
+ );
1040
+ }
1041
+
863
1042
  interface SubAgentDraft {
864
1043
  name: string;
865
1044
  description: string;
@@ -870,11 +1049,13 @@ function SubAgentsSection({
870
1049
  subAgents,
871
1050
  editable,
872
1051
  isSaving,
1052
+ error,
873
1053
  onSave,
874
1054
  }: {
875
1055
  readonly subAgents: readonly SubAgent[];
876
1056
  readonly editable?: boolean;
877
1057
  readonly isSaving?: boolean;
1058
+ readonly error?: string | null;
878
1059
  readonly onSave?: (subs: import("@stigmer/sdk").SubAgentInput[]) => Promise<boolean>;
879
1060
  }) {
880
1061
  const [expanded, setExpanded] = useState<Set<number>>(new Set());
@@ -1025,6 +1206,12 @@ function SubAgentsSection({
1025
1206
  </div>
1026
1207
  )}
1027
1208
 
1209
+ {error && (
1210
+ <p className="border-t border-border px-3 py-2 text-xs text-destructive" role="alert">
1211
+ {error}
1212
+ </p>
1213
+ )}
1214
+
1028
1215
  {editable && showAddForm && (
1029
1216
  <div className="border-t border-border p-3 space-y-2">
1030
1217
  <input
@@ -0,0 +1,166 @@
1
+ // Tests for the Datastores section on AgentDetailView (stigmer/stigmer#319):
2
+ // read-mode rendering and navigation, the inline-edit save payload (which
3
+ // must round-trip the full spec through agentToInput), and the DD-006
4
+ // surfacing of the backend's datastore exposure guard refusal.
5
+
6
+ import { describe, it, expect, vi, beforeAll, afterEach, beforeEach, type Mock } from "vitest";
7
+ import { render, screen, cleanup, waitFor } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import type { ReactNode } from "react";
10
+ import { create } from "@bufbuild/protobuf";
11
+ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
12
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
13
+ import type { AgentInput } from "@stigmer/sdk";
14
+ import { StigmerContext } from "../../context";
15
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
16
+ import { AgentDetailView } from "../AgentDetailView";
17
+
18
+ beforeAll(() => {
19
+ HTMLDialogElement.prototype.showModal = function showModal() {
20
+ this.open = true;
21
+ };
22
+ HTMLDialogElement.prototype.close = function close() {
23
+ this.open = false;
24
+ };
25
+ });
26
+
27
+ afterEach(() => cleanup());
28
+ beforeEach(() => vi.restoreAllMocks());
29
+
30
+ function makeAgent() {
31
+ return create(AgentSchema, {
32
+ metadata: {
33
+ id: "agt_1",
34
+ name: "Clinic Assistant",
35
+ slug: "clinic-assistant",
36
+ org: "acme",
37
+ },
38
+ spec: {
39
+ instructions: "You are a careful clinic assistant.",
40
+ datastoreUsages: [
41
+ {
42
+ datastoreRef: {
43
+ org: "acme",
44
+ slug: "clinic-records",
45
+ kind: ApiResourceKind.datastore,
46
+ },
47
+ },
48
+ {
49
+ datastoreRef: {
50
+ org: "partner",
51
+ slug: "shared-records",
52
+ kind: ApiResourceKind.datastore,
53
+ },
54
+ },
55
+ ],
56
+ },
57
+ });
58
+ }
59
+
60
+ function renderView(overrides: {
61
+ update?: Mock;
62
+ onDatastoreClick?: (ref: { org: string; slug: string }) => void;
63
+ editable?: boolean;
64
+ }) {
65
+ const agent = makeAgent();
66
+ const update = overrides.update ?? vi.fn().mockResolvedValue(agent);
67
+ const stigmer = {
68
+ agent: {
69
+ getByReference: vi.fn().mockResolvedValue(agent),
70
+ update,
71
+ },
72
+ iamPolicy: {
73
+ checkMyPermission: vi.fn().mockResolvedValue({ isAuthorized: false }),
74
+ },
75
+ };
76
+
77
+ function Wrapper({ children }: { children: ReactNode }) {
78
+ return (
79
+ <FetchCacheContext.Provider value={null}>
80
+ <StigmerContext.Provider value={stigmer as never}>
81
+ {children}
82
+ </StigmerContext.Provider>
83
+ </FetchCacheContext.Provider>
84
+ );
85
+ }
86
+
87
+ const result = render(
88
+ <AgentDetailView
89
+ org="acme"
90
+ slug="clinic-assistant"
91
+ editable={overrides.editable}
92
+ onDatastoreClick={overrides.onDatastoreClick}
93
+ />,
94
+ { wrapper: Wrapper },
95
+ );
96
+ return { ...result, update };
97
+ }
98
+
99
+ describe("AgentDetailView — Datastores section (read mode)", () => {
100
+ it("renders attached datastores with org-qualified labels for cross-org refs", async () => {
101
+ renderView({});
102
+
103
+ expect(await screen.findByText("Datastores")).toBeTruthy();
104
+ expect(screen.getByText("clinic-records")).toBeTruthy();
105
+ expect(screen.getByText("partner/shared-records")).toBeTruthy();
106
+ });
107
+
108
+ it("fires onDatastoreClick with the resolved org and slug", async () => {
109
+ const user = userEvent.setup();
110
+ const onDatastoreClick = vi.fn();
111
+ renderView({ onDatastoreClick });
112
+
113
+ await user.click(await screen.findByText("clinic-records"));
114
+ expect(onDatastoreClick).toHaveBeenCalledWith({
115
+ org: "acme",
116
+ slug: "clinic-records",
117
+ });
118
+
119
+ await user.click(screen.getByText("partner/shared-records"));
120
+ expect(onDatastoreClick).toHaveBeenCalledWith({
121
+ org: "partner",
122
+ slug: "shared-records",
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("AgentDetailView — Datastores section (inline edit)", () => {
128
+ it("saves the full reconstructed input with correctly-shaped datastoreUsages", async () => {
129
+ const user = userEvent.setup();
130
+ const { update } = renderView({ editable: true });
131
+
132
+ await user.click(await screen.findByLabelText("Edit datastores"));
133
+ await user.click(screen.getByLabelText("Remove partner/shared-records"));
134
+ await user.click(screen.getByRole("button", { name: "Save changes" }));
135
+
136
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
137
+ const input = update.mock.calls[0][0] as AgentInput;
138
+
139
+ expect(input.datastoreUsages).toEqual([
140
+ { datastoreRef: { org: "acme", slug: "clinic-records" } },
141
+ ]);
142
+ // The rest of the spec must ride along — a section save is a full
143
+ // spec replacement reconstructed via agentToInput().
144
+ expect(input.instructions).toBe("You are a careful clinic assistant.");
145
+ expect(input.org).toBe("acme");
146
+ expect(input.slug).toBe("clinic-assistant");
147
+ });
148
+
149
+ it("surfaces the backend's exposure-guard refusal in the section editor (DD-006)", async () => {
150
+ const user = userEvent.setup();
151
+ const guardMessage =
152
+ 'agent "clinic-assistant" cannot be public while it uses datastores ' +
153
+ "(clinic-records): multi-tenant datastore sharing is not supported — " +
154
+ "keep the agent private or org-visible, or remove its datastore_usages";
155
+ renderView({
156
+ editable: true,
157
+ update: vi.fn().mockRejectedValue(new Error(guardMessage)),
158
+ });
159
+
160
+ await user.click(await screen.findByLabelText("Edit datastores"));
161
+ await user.click(screen.getByRole("button", { name: "Save changes" }));
162
+
163
+ const alert = await screen.findByRole("alert");
164
+ expect(alert.textContent).toContain(guardMessage);
165
+ });
166
+ });