@stigmer/react 3.4.1 → 3.5.0

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 (93) 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/sidebar/SettingsSidebar.d.ts +62 -0
  45. package/sidebar/SettingsSidebar.d.ts.map +1 -0
  46. package/sidebar/SettingsSidebar.js +60 -0
  47. package/sidebar/SettingsSidebar.js.map +1 -0
  48. package/sidebar/WorkspaceSidebar.d.ts +87 -0
  49. package/sidebar/WorkspaceSidebar.d.ts.map +1 -0
  50. package/sidebar/WorkspaceSidebar.js +95 -0
  51. package/sidebar/WorkspaceSidebar.js.map +1 -0
  52. package/sidebar/chrome.d.ts +48 -0
  53. package/sidebar/chrome.d.ts.map +1 -0
  54. package/sidebar/chrome.js +32 -0
  55. package/sidebar/chrome.js.map +1 -0
  56. package/sidebar/index.d.ts +6 -0
  57. package/sidebar/index.d.ts.map +1 -0
  58. package/sidebar/index.js +9 -0
  59. package/sidebar/index.js.map +1 -0
  60. package/sidebar/types.d.ts +54 -0
  61. package/sidebar/types.d.ts.map +1 -0
  62. package/sidebar/types.js +2 -0
  63. package/sidebar/types.js.map +1 -0
  64. package/src/agent/AgentDetailView.tsx +198 -11
  65. package/src/agent/__tests__/AgentDetailView.datastores.test.tsx +166 -0
  66. package/src/agent/internal/__tests__/agentToInput.test.ts +154 -0
  67. package/src/agent/internal/agentToInput.ts +25 -7
  68. package/src/api-key/ApiKeyListPanel.tsx +17 -4
  69. package/src/dependency-graph/DependencyTreeNode.tsx +8 -0
  70. package/src/dependency-graph/__tests__/useDependencyGraph.test.ts +108 -0
  71. package/src/dependency-graph/types.ts +20 -8
  72. package/src/dependency-graph/useDependencyGraph.ts +23 -1
  73. package/src/index.ts +13 -0
  74. package/src/internal/scroll-area.tsx +37 -0
  75. package/src/internal/tooltip.tsx +66 -0
  76. package/src/mcp-server/StdioSandboxNotice.tsx +16 -14
  77. package/src/mcp-server/__tests__/StdioSandboxNotice.test.tsx +3 -2
  78. package/src/mcp-server/steps/IdentityTransportStep.tsx +7 -0
  79. package/src/resource-creation/templates/mcp-server-templates.ts +17 -14
  80. package/src/sidebar/SettingsSidebar.tsx +173 -0
  81. package/src/sidebar/WorkspaceSidebar.tsx +386 -0
  82. package/src/sidebar/__tests__/SettingsSidebar.test.tsx +107 -0
  83. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +236 -0
  84. package/src/sidebar/chrome.tsx +129 -0
  85. package/src/sidebar/index.ts +16 -0
  86. package/src/sidebar/types.ts +55 -0
  87. package/src/test/__tests__/samples.test.ts +2 -0
  88. package/src/test/samples.ts +48 -0
  89. package/styles.css +1 -1
  90. package/test/samples.d.ts +21 -0
  91. package/test/samples.d.ts.map +1 -1
  92. package/test/samples.js +32 -0
  93. package/test/samples.js.map +1 -1
@@ -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
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { create, equals, isFieldSet, toJson } from "@bufbuild/protobuf";
3
+ import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
4
+ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
5
+ import { AgentSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
6
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
7
+ import { buildAgentProto } from "@stigmer/sdk";
8
+ import { agentToInput } from "../agentToInput";
9
+
10
+ /**
11
+ * Regression guard for stigmer/stigmer#319 — the console agent editor
12
+ * silently deleting `datastore_usages` on save.
13
+ *
14
+ * `agentToInput()` reconstructs a full `AgentInput` from a fetched Agent
15
+ * so inline edits can re-submit the whole spec (the backend does full
16
+ * spec replacement). Any spec field the converter does not map is
17
+ * therefore *cleared* by the next console edit. Two tests enforce the
18
+ * converter's "must be kept exhaustive" contract:
19
+ *
20
+ * 1. The round-trip test proves no field of the fixture below survives
21
+ * conversion distorted or dropped.
22
+ * 2. The tripwire test proves the fixture itself covers every
23
+ * `AgentSpec` field, via schema reflection. When a new proto field
24
+ * lands, the tripwire fails until the fixture sets it — and the
25
+ * round-trip then fails until `agentToInput()` maps it.
26
+ *
27
+ * Nested messages are covered by population, not reflection: every
28
+ * sub-field of the fixture (ref `version` pins, approval overrides,
29
+ * sub-agent grants, env metadata) must carry a non-default value so the
30
+ * round-trip comparison can catch its loss.
31
+ */
32
+ function fullyPopulatedAgent(): Agent {
33
+ return create(AgentSchema, {
34
+ apiVersion: "agentic.stigmer.ai/v1",
35
+ kind: "Agent",
36
+ metadata: {
37
+ id: "agt_test",
38
+ name: "Clinic Assistant",
39
+ slug: "clinic-assistant",
40
+ org: "acme",
41
+ labels: { team: "care-ops" },
42
+ },
43
+ spec: {
44
+ description: "Front-desk assistant for the clinic pilot.",
45
+ iconUrl: "https://example.com/clinic.svg",
46
+ instructions: "You are a careful clinic assistant. Verify identity first.",
47
+ mcpServerUsages: [
48
+ {
49
+ mcpServerRef: {
50
+ org: "acme",
51
+ slug: "github",
52
+ kind: ApiResourceKind.mcp_server,
53
+ },
54
+ enabledTools: ["create_issue", "search_issues"],
55
+ toolApprovalOverrides: [
56
+ {
57
+ toolName: "create_issue",
58
+ requiresApproval: true,
59
+ message: "Create issue {{args.title}}?",
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ skillRefs: [
65
+ {
66
+ org: "acme",
67
+ slug: "triage-guide",
68
+ kind: ApiResourceKind.skill,
69
+ // The version pin (tag or hash) must survive conversion — a
70
+ // dropped pin silently resets the skill to "latest".
71
+ version: "stable",
72
+ },
73
+ ],
74
+ subAgents: [
75
+ {
76
+ name: "researcher",
77
+ description: "Looks up prior cases",
78
+ instructions: "Research thoroughly before answering anything.",
79
+ mcpAccess: [{ mcpServer: "github", enabledTools: ["search_issues"] }],
80
+ skillRefs: [
81
+ {
82
+ org: "acme",
83
+ slug: "research-methods",
84
+ kind: ApiResourceKind.skill,
85
+ version: "v1.2",
86
+ },
87
+ ],
88
+ modelOverride: "claude-sonnet",
89
+ },
90
+ ],
91
+ env: {
92
+ CLINIC_API_KEY: {
93
+ isSecret: true,
94
+ description: "Records bridge credential",
95
+ },
96
+ REGION: { optional: true },
97
+ },
98
+ datastoreUsages: [
99
+ {
100
+ datastoreRef: {
101
+ org: "acme",
102
+ slug: "clinic-records",
103
+ kind: ApiResourceKind.datastore,
104
+ },
105
+ },
106
+ ],
107
+ },
108
+ });
109
+ }
110
+
111
+ describe("agentToInput", () => {
112
+ it("round-trips a fully-populated spec through AgentInput without loss", () => {
113
+ const agent = fullyPopulatedAgent();
114
+
115
+ const rebuilt = buildAgentProto(agentToInput(agent));
116
+
117
+ // toJson gives a readable structural diff on failure; the protobuf
118
+ // equals() assertion below is the authoritative semantic check.
119
+ expect(toJson(AgentSpecSchema, rebuilt.spec!)).toEqual(
120
+ toJson(AgentSpecSchema, agent.spec!),
121
+ );
122
+ expect(equals(AgentSpecSchema, rebuilt.spec!, agent.spec!)).toBe(true);
123
+ });
124
+
125
+ it("round-trips the input-owned metadata fields", () => {
126
+ const agent = fullyPopulatedAgent();
127
+
128
+ const rebuilt = buildAgentProto(agentToInput(agent));
129
+
130
+ // Only the fields AgentInput owns: id is server-assigned, and
131
+ // visibility is deliberately unmapped (the backend preserves it on
132
+ // omit — see preserveImmutableFields in the update pipeline).
133
+ expect(rebuilt.metadata?.name).toBe(agent.metadata?.name);
134
+ expect(rebuilt.metadata?.org).toBe(agent.metadata?.org);
135
+ expect(rebuilt.metadata?.slug).toBe(agent.metadata?.slug);
136
+ expect(rebuilt.metadata?.labels).toEqual(agent.metadata?.labels);
137
+ });
138
+
139
+ it("populates every AgentSpec field in the fixture (new-field tripwire)", () => {
140
+ const spec = fullyPopulatedAgent().spec!;
141
+
142
+ const unsetFields = AgentSpecSchema.fields
143
+ .filter((field) => !isFieldSet(spec, field))
144
+ .map((field) => field.name);
145
+
146
+ expect(
147
+ unsetFields,
148
+ `AgentSpec gained field(s) [${unsetFields.join(", ")}] that the ` +
149
+ "fixture does not populate. Set them in fullyPopulatedAgent() AND " +
150
+ "map them in agentToInput() — an unmapped spec field is cleared " +
151
+ "by every console inline edit (stigmer/stigmer#319).",
152
+ ).toEqual([]);
153
+ });
154
+ });
@@ -1,16 +1,12 @@
1
1
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
2
2
  import type {
3
3
  AgentInput,
4
+ DatastoreUsageInput,
5
+ EnvVarDeclarationInput,
4
6
  McpServerUsageInput,
5
7
  SubAgentInput,
6
8
  } from "@stigmer/sdk";
7
9
 
8
- interface EnvVarDeclarationInput {
9
- isSecret?: boolean;
10
- description?: string;
11
- optional?: boolean;
12
- }
13
-
14
10
  /**
15
11
  * Converts a fetched Agent proto to the AgentInput shape expected by
16
12
  * `stigmer.agent.update()`. This enables inline field editing: read the
@@ -18,16 +14,23 @@ interface EnvVarDeclarationInput {
18
14
  *
19
15
  * Must be kept exhaustive — any spec field not mapped here will be
20
16
  * cleared on the next update (the backend does full spec replacement).
17
+ * Enforced by `__tests__/agentToInput.test.ts`: a schema tripwire fails
18
+ * when `AgentSpec` gains a field this converter does not round-trip
19
+ * (stigmer/stigmer#319 was exactly such a drop).
21
20
  */
22
21
  export function agentToInput(agent: Agent): AgentInput {
23
22
  const meta = agent.metadata;
24
23
  const spec = agent.spec;
25
24
 
25
+ // Resource refs preserve `version` (the tag/hash pin on versioned
26
+ // resources like Skills) — dropping it would silently reset a pinned
27
+ // reference to "latest" on the next inline edit.
26
28
  const mcpServerUsages: McpServerUsageInput[] | undefined =
27
29
  spec?.mcpServerUsages?.map((usage) => ({
28
30
  mcpServerRef: {
29
31
  org: usage.mcpServerRef?.org ?? "",
30
32
  slug: usage.mcpServerRef?.slug ?? "",
33
+ version: usage.mcpServerRef?.version || undefined,
31
34
  },
32
35
  enabledTools: usage.enabledTools.length > 0 ? [...usage.enabledTools] : undefined,
33
36
  toolApprovalOverrides:
@@ -44,6 +47,7 @@ export function agentToInput(agent: Agent): AgentInput {
44
47
  spec?.skillRefs?.map((ref) => ({
45
48
  org: ref.org || "",
46
49
  slug: ref.slug,
50
+ version: ref.version || undefined,
47
51
  }));
48
52
 
49
53
  const subAgents: SubAgentInput[] | undefined =
@@ -60,11 +64,24 @@ export function agentToInput(agent: Agent): AgentInput {
60
64
  : undefined,
61
65
  skillRefs:
62
66
  sa.skillRefs.length > 0
63
- ? sa.skillRefs.map((r) => ({ org: r.org || "", slug: r.slug }))
67
+ ? sa.skillRefs.map((r) => ({
68
+ org: r.org || "",
69
+ slug: r.slug,
70
+ version: r.version || undefined,
71
+ }))
64
72
  : undefined,
65
73
  modelOverride: sa.modelOverride || undefined,
66
74
  }));
67
75
 
76
+ const datastoreUsages: DatastoreUsageInput[] | undefined =
77
+ spec?.datastoreUsages?.map((usage) => ({
78
+ datastoreRef: {
79
+ org: usage.datastoreRef?.org ?? "",
80
+ slug: usage.datastoreRef?.slug ?? "",
81
+ version: usage.datastoreRef?.version || undefined,
82
+ },
83
+ }));
84
+
68
85
  let env: Record<string, EnvVarDeclarationInput> | undefined;
69
86
  if (spec?.env && Object.keys(spec.env).length > 0) {
70
87
  env = {};
@@ -91,5 +108,6 @@ export function agentToInput(agent: Agent): AgentInput {
91
108
  skillRefs: skillRefs?.length ? skillRefs : undefined,
92
109
  subAgents: subAgents?.length ? subAgents : undefined,
93
110
  env,
111
+ datastoreUsages: datastoreUsages?.length ? datastoreUsages : undefined,
94
112
  };
95
113
  }
@@ -18,6 +18,13 @@ export interface ApiKeyListPanelProps {
18
18
  readonly onRefetchRef?: (refetch: () => void) => void;
19
19
  /** Additional CSS class names for the root container. */
20
20
  readonly className?: string;
21
+ /**
22
+ * Reference instant for the "last used" relative stamp. Defaults to
23
+ * the live clock. **Deterministic hosts (documentation embeds, video
24
+ * export) must pass a frozen instant** — otherwise a depicted key's
25
+ * "2h ago" drifts with every replay.
26
+ */
27
+ readonly now?: Date;
21
28
  }
22
29
 
23
30
  /**
@@ -46,6 +53,7 @@ export interface ApiKeyListPanelProps {
46
53
  export function ApiKeyListPanel({
47
54
  onRefetchRef,
48
55
  className,
56
+ now,
49
57
  }: ApiKeyListPanelProps) {
50
58
  const { apiKeys, isLoading, error, refetch } = useApiKeyList();
51
59
  const [confirmingId, setConfirmingId] = useState<string | null>(null);
@@ -104,6 +112,7 @@ export function ApiKeyListPanel({
104
112
  <ApiKeyRow
105
113
  key={id}
106
114
  apiKey={key}
115
+ now={now}
107
116
  isConfirming={confirmingId === id}
108
117
  onConfirmDelete={() => setConfirmingId(id)}
109
118
  onCancelDelete={() => setConfirmingId(null)}
@@ -124,12 +133,14 @@ export function ApiKeyListPanel({
124
133
 
125
134
  function ApiKeyRow({
126
135
  apiKey,
136
+ now,
127
137
  isConfirming,
128
138
  onConfirmDelete,
129
139
  onCancelDelete,
130
140
  onDeleted,
131
141
  }: {
132
142
  apiKey: ApiKey;
143
+ now?: Date;
133
144
  isConfirming: boolean;
134
145
  onConfirmDelete: () => void;
135
146
  onCancelDelete: () => void;
@@ -243,7 +254,9 @@ function ApiKeyRow({
243
254
  : "No expiry"}
244
255
  </span>
245
256
  <span>
246
- {lastUsedAt ? formatRelativeTime(timestampDate(lastUsedAt)) : "Never used"}
257
+ {lastUsedAt
258
+ ? formatRelativeTime(timestampDate(lastUsedAt), now)
259
+ : "Never used"}
247
260
  </span>
248
261
  </div>
249
262
 
@@ -276,9 +289,9 @@ function formatShortDate(date: Date): string {
276
289
  });
277
290
  }
278
291
 
279
- function formatRelativeTime(date: Date): string {
280
- const now = Date.now();
281
- const diffMs = now - date.getTime();
292
+ function formatRelativeTime(date: Date, now?: Date): string {
293
+ const ref = now?.getTime() ?? Date.now();
294
+ const diffMs = ref - date.getTime();
282
295
 
283
296
  if (diffMs < 0) return "Just now";
284
297
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useCallback, useState, type KeyboardEvent } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
+ import { DatastoreIcon } from "../datastore/DatastoreDetailView.js";
5
6
  import type { DependencyNode, NodeKind } from "./types.js";
6
7
 
7
8
  interface DependencyTreeNodeProps {
@@ -15,6 +16,7 @@ const KIND_LABELS: Record<NodeKind, string> = {
15
16
  agent: "Agent",
16
17
  "mcp-server": "MCP",
17
18
  skill: "Skill",
19
+ datastore: "Datastore",
18
20
  "sub-agent": "Sub-Agent",
19
21
  };
20
22
 
@@ -179,6 +181,10 @@ function kindBadgeClasses(kind: NodeKind): string {
179
181
  return "bg-[var(--stgm-status-running-subtle)] text-[var(--stgm-status-running)]";
180
182
  case "skill":
181
183
  return "bg-[var(--stgm-status-pending-subtle)] text-[var(--stgm-status-pending)]";
184
+ // Badges borrow status hues as category colors (skill=pending,
185
+ // sub-agent=ready); datastore takes the remaining distinct hue.
186
+ case "datastore":
187
+ return "bg-[var(--stgm-status-degraded-subtle)] text-[var(--stgm-status-degraded)]";
182
188
  case "sub-agent":
183
189
  return "bg-[var(--stgm-status-ready-subtle)] text-[var(--stgm-status-ready)]";
184
190
  case "agent":
@@ -205,6 +211,8 @@ function NodeIcon({
205
211
  return <McpServerIcon className={className} />;
206
212
  case "skill":
207
213
  return <SkillIcon className={className} />;
214
+ case "datastore":
215
+ return <DatastoreIcon className={className} />;
208
216
  }
209
217
  }
210
218
 
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderHook } from "@testing-library/react";
3
+ import { useDependencyGraph } from "../useDependencyGraph";
4
+
5
+ const emptySpec = {
6
+ mcpServerUsages: [],
7
+ skillRefs: [],
8
+ subAgents: [],
9
+ };
10
+
11
+ function graphFor(spec: Parameters<typeof useDependencyGraph>[0]["spec"]) {
12
+ const { result } = renderHook(() =>
13
+ useDependencyGraph({ agentName: "clinic-assistant", agentOrg: "acme", spec }),
14
+ );
15
+ return result.current;
16
+ }
17
+
18
+ describe("useDependencyGraph — datastore nodes", () => {
19
+ it("derives a datastore node with a navigation ref", () => {
20
+ const { tree, isEmpty } = graphFor({
21
+ ...emptySpec,
22
+ datastoreUsages: [
23
+ { datastoreRef: { org: "acme", slug: "clinic-records" } },
24
+ ],
25
+ });
26
+
27
+ // A datastore-only agent has dependencies — the tab must appear.
28
+ expect(isEmpty).toBe(false);
29
+ expect(tree).not.toBeNull();
30
+ expect(tree!.nodeCount).toBe(2);
31
+
32
+ const node = tree!.root.children[0];
33
+ expect(node).toMatchObject({
34
+ id: "datastore:clinic-records",
35
+ kind: "datastore",
36
+ label: "clinic-records",
37
+ ref: { org: "acme", slug: "clinic-records" },
38
+ });
39
+ // Same-org ref gets no qualified label.
40
+ expect(node.qualifiedLabel).toBeUndefined();
41
+ });
42
+
43
+ it("qualifies cross-org datastore labels and falls back to the agent org for empty refs", () => {
44
+ const { tree } = graphFor({
45
+ ...emptySpec,
46
+ datastoreUsages: [
47
+ { datastoreRef: { org: "partner", slug: "shared-records" } },
48
+ { datastoreRef: { org: "", slug: "local-records" } },
49
+ ],
50
+ });
51
+
52
+ const [crossOrg, relative] = tree!.root.children;
53
+ expect(crossOrg.qualifiedLabel).toBe("partner/shared-records");
54
+ expect(crossOrg.ref).toEqual({ org: "partner", slug: "shared-records" });
55
+ expect(relative.qualifiedLabel).toBeUndefined();
56
+ expect(relative.ref).toEqual({ org: "acme", slug: "local-records" });
57
+ });
58
+
59
+ it("orders children to mirror the Overview sections: MCP, skills, datastores, sub-agents", () => {
60
+ const { tree } = graphFor({
61
+ mcpServerUsages: [
62
+ {
63
+ mcpServerRef: { org: "acme", slug: "github" },
64
+ enabledTools: [],
65
+ toolApprovalOverrides: [],
66
+ },
67
+ ],
68
+ skillRefs: [{ org: "acme", slug: "triage-guide" }],
69
+ subAgents: [
70
+ {
71
+ name: "researcher",
72
+ description: "",
73
+ mcpAccess: [],
74
+ skillRefs: [],
75
+ modelOverride: "",
76
+ },
77
+ ],
78
+ datastoreUsages: [
79
+ { datastoreRef: { org: "acme", slug: "clinic-records" } },
80
+ ],
81
+ });
82
+
83
+ expect(tree!.root.children.map((c) => c.kind)).toEqual([
84
+ "mcp-server",
85
+ "skill",
86
+ "datastore",
87
+ "sub-agent",
88
+ ]);
89
+ expect(tree!.nodeCount).toBe(5);
90
+ });
91
+
92
+ it("accepts specs without the datastoreUsages field (pre-existing public callers)", () => {
93
+ const { tree, isEmpty } = graphFor({
94
+ ...emptySpec,
95
+ skillRefs: [{ org: "acme", slug: "triage-guide" }],
96
+ });
97
+
98
+ expect(isEmpty).toBe(false);
99
+ expect(tree!.root.children.map((c) => c.kind)).toEqual(["skill"]);
100
+ });
101
+
102
+ it("stays empty when no dependencies exist at all", () => {
103
+ const { tree, isEmpty } = graphFor({ ...emptySpec, datastoreUsages: [] });
104
+
105
+ expect(isEmpty).toBe(true);
106
+ expect(tree).toBeNull();
107
+ });
108
+ });
@@ -4,9 +4,10 @@
4
4
  * - `"agent"` — the root agent (always the tree root)
5
5
  * - `"mcp-server"` — an MCP server referenced via `mcpServerUsages` or `mcpAccess`
6
6
  * - `"skill"` — a skill referenced via `skillRefs`
7
+ * - `"datastore"` — a datastore referenced via `datastoreUsages`
7
8
  * - `"sub-agent"` — an inline sub-agent definition with its own dependencies
8
9
  */
9
- export type NodeKind = "agent" | "mcp-server" | "skill" | "sub-agent";
10
+ export type NodeKind = "agent" | "mcp-server" | "skill" | "datastore" | "sub-agent";
10
11
 
11
12
  /**
12
13
  * A single node in the agent dependency tree.
@@ -15,9 +16,9 @@ export type NodeKind = "agent" | "mcp-server" | "skill" | "sub-agent";
15
16
  * (MCP servers and skills they access). Edges are implicit in the
16
17
  * parent-child relationship — no separate edge type is needed.
17
18
  *
18
- * Nodes are navigable when `ref` is defined (MCP servers, skills).
19
- * Sub-agent nodes have no `ref` because they are inline definitions
20
- * within the agent spec, not standalone resources.
19
+ * Nodes are navigable when `ref` is defined (MCP servers, skills,
20
+ * datastores). Sub-agent nodes have no `ref` because they are inline
21
+ * definitions within the agent spec, not standalone resources.
21
22
  */
22
23
  export interface DependencyNode {
23
24
  /**
@@ -46,9 +47,9 @@ export interface DependencyNode {
46
47
  /** Child nodes. Empty array for leaf nodes (MCP servers, skills). */
47
48
  readonly children: readonly DependencyNode[];
48
49
  /**
49
- * Navigation reference for clickable nodes. Defined for MCP servers
50
- * and skills (standalone resources). Undefined for sub-agents (inline
51
- * definitions) and the root agent.
50
+ * Navigation reference for clickable nodes. Defined for MCP servers,
51
+ * skills, and datastores (standalone resources). Undefined for
52
+ * sub-agents (inline definitions) and the root agent.
52
53
  */
53
54
  readonly ref?: { readonly org: string; readonly slug: string };
54
55
  }
@@ -70,7 +71,7 @@ export interface DependencyGraphProps {
70
71
  /**
71
72
  * Called when a navigable node is clicked. The `node.ref` field
72
73
  * contains the `org` and `slug` for routing. Only fired for nodes
73
- * where `ref` is defined (MCP servers, skills).
74
+ * where `ref` is defined (MCP servers, skills, datastores).
74
75
  */
75
76
  readonly onNodeClick?: (node: DependencyNode) => void;
76
77
  /**
@@ -119,6 +120,17 @@ export interface UseDependencyGraphOptions {
119
120
  }[];
120
121
  readonly modelOverride: string;
121
122
  }[];
123
+ /**
124
+ * Datastore attachments (`spec.datastoreUsages`). Optional because
125
+ * this hook is a public SDK export: callers passing hand-built spec
126
+ * objects written before the field existed must keep compiling.
127
+ */
128
+ readonly datastoreUsages?: readonly {
129
+ readonly datastoreRef?: {
130
+ readonly org: string;
131
+ readonly slug: string;
132
+ };
133
+ }[];
122
134
  } | undefined;
123
135
  }
124
136