@peers-app/peers-ui 0.22.0 → 0.23.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 (31) hide show
  1. package/dist/components/access-level-field.d.ts +10 -0
  2. package/dist/components/access-level-field.js +28 -0
  3. package/dist/components/access-level-field.test.d.ts +1 -0
  4. package/dist/components/access-level-field.test.js +28 -0
  5. package/dist/components/message-logs/message-logs.d.ts +2 -1
  6. package/dist/components/message-logs/message-logs.js +44 -2
  7. package/dist/components/message-logs/tool-access-approval-card.d.ts +13 -0
  8. package/dist/components/message-logs/tool-access-approval-card.js +81 -0
  9. package/dist/components/message-logs/tool-access-approval-card.test.d.ts +1 -0
  10. package/dist/components/message-logs/tool-access-approval-card.test.js +102 -0
  11. package/dist/screens/assistants/assistant-info.d.ts +1 -0
  12. package/dist/screens/assistants/assistant-info.js +3 -1
  13. package/dist/screens/assistants/assistant-list.d.ts +1 -0
  14. package/dist/screens/assistants/assistant-list.js +8 -1
  15. package/dist/screens/tools/tool-access-ipc.d.ts +5 -0
  16. package/dist/screens/tools/tool-access-ipc.js +65 -0
  17. package/dist/screens/tools/tool-access-ipc.test.d.ts +1 -0
  18. package/dist/screens/tools/tool-access-ipc.test.js +40 -0
  19. package/dist/screens/tools/tool-info.d.ts +1 -0
  20. package/dist/screens/tools/tool-info.js +3 -1
  21. package/package.json +3 -3
  22. package/src/components/access-level-field.test.tsx +32 -0
  23. package/src/components/access-level-field.tsx +68 -0
  24. package/src/components/message-logs/message-logs.tsx +58 -1
  25. package/src/components/message-logs/tool-access-approval-card.test.tsx +127 -0
  26. package/src/components/message-logs/tool-access-approval-card.tsx +178 -0
  27. package/src/screens/assistants/assistant-info.tsx +4 -0
  28. package/src/screens/assistants/assistant-list.tsx +29 -2
  29. package/src/screens/tools/tool-access-ipc.test.ts +55 -0
  30. package/src/screens/tools/tool-access-ipc.ts +88 -0
  31. package/src/screens/tools/tool-info.tsx +4 -0
@@ -0,0 +1,10 @@
1
+ import { type Observable } from "@peers-app/peers-sdk";
2
+ interface AccessLevelFieldProps {
3
+ value: number | Observable<number | undefined>;
4
+ disabled?: boolean;
5
+ }
6
+ /**
7
+ * Named-preset plus custom 0–100 editor for tool and Assistant access levels.
8
+ */
9
+ export declare function AccessLevelField(props: AccessLevelFieldProps): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AccessLevelField = AccessLevelField;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
6
+ const hooks_1 = require("../hooks");
7
+ const input_1 = require("./input");
8
+ /**
9
+ * Named-preset plus custom 0–100 editor for tool and Assistant access levels.
10
+ */
11
+ function AccessLevelField(props) {
12
+ const [value, setValue] = (0, hooks_1.useObservable)(props.value);
13
+ const level = (0, peers_sdk_1.resolveAccessLevel)(value);
14
+ const presets = (0, peers_sdk_1.listAccessLevelPresets)();
15
+ const isNamed = presets.some((preset) => preset.value === level);
16
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("small", { children: "Access level:" }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 align-items-center", children: [(0, jsx_runtime_1.jsxs)("select", { className: "form-select form-select-sm", disabled: props.disabled, value: isNamed ? String(level) : "custom", onChange: (evt) => {
17
+ if (evt.target.value === "custom") {
18
+ setValue(level);
19
+ return;
20
+ }
21
+ setValue(Number(evt.target.value));
22
+ }, children: [presets.map((preset) => ((0, jsx_runtime_1.jsxs)("option", { value: preset.value, children: [preset.label, " (", preset.value, ")"] }, preset.value))), (0, jsx_runtime_1.jsx)("option", { value: "custom", children: "Custom" })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { type: "number", min: 0, max: 100, disabled: props.disabled, className: "form-control form-control-sm", style: { maxWidth: 90 }, value: level, onChange: (evt) => {
23
+ const next = Number(evt.target.value);
24
+ if (Number.isInteger(next) && next >= 0 && next <= 100) {
25
+ setValue(next);
26
+ }
27
+ } })] }), (0, jsx_runtime_1.jsxs)("div", { className: "form-text", children: [(0, peers_sdk_1.getAccessLevelLabel)(level), " (", level, "). Unclassified records default to Self (100)."] })] }));
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ const react_1 = require("@testing-library/react");
6
+ const access_level_field_1 = require("./access-level-field");
7
+ describe("AccessLevelField", () => {
8
+ it("shows the named preset and current label", () => {
9
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(access_level_field_1.AccessLevelField, { value: (0, peers_sdk_1.observable)(40) }));
10
+ expect(react_1.screen.getByRole("combobox")).toHaveValue("40");
11
+ expect(react_1.screen.getByText(/Unclassified records default to Self/)).toHaveTextContent("Writer (40)");
12
+ });
13
+ it("writes a named preset onto the observable", () => {
14
+ const value = (0, peers_sdk_1.observable)(40);
15
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(access_level_field_1.AccessLevelField, { value: value }));
16
+ react_1.fireEvent.change(react_1.screen.getByRole("combobox"), { target: { value: "60" } });
17
+ expect(value()).toBe(60);
18
+ expect(react_1.screen.getByText(/Unclassified records default to Self/)).toHaveTextContent("Admin (60)");
19
+ });
20
+ it("edits a custom integer between named tiers", () => {
21
+ const value = (0, peers_sdk_1.observable)(45);
22
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(access_level_field_1.AccessLevelField, { value: value }));
23
+ expect(react_1.screen.getByRole("combobox")).toHaveValue("custom");
24
+ react_1.fireEvent.change(react_1.screen.getByRole("spinbutton"), { target: { value: "47" } });
25
+ expect(value()).toBe(47);
26
+ expect(react_1.screen.getByText(/Writer\+ \(47\)/)).toBeInTheDocument();
27
+ });
28
+ });
@@ -1,5 +1,6 @@
1
1
  interface MessageLogsProps {
2
2
  messageId: string;
3
3
  }
4
- export declare const MessageLogs: ({ messageId }: MessageLogsProps) => false | import("react/jsx-runtime").JSX.Element;
4
+ /** Workflow logs and tool-access approval cards for a thread message. */
5
+ export declare const MessageLogs: ({ messageId }: MessageLogsProps) => import("react/jsx-runtime").JSX.Element;
5
6
  export {};
@@ -11,6 +11,8 @@ const moment_1 = __importDefault(require("moment"));
11
11
  const react_1 = require("react");
12
12
  const hooks_1 = require("../../hooks");
13
13
  const markdown_with_mentions_1 = require("../markdown-with-mentions");
14
+ const tool_access_approval_card_1 = require("./tool-access-approval-card");
15
+ /** Workflow logs and tool-access approval cards for a thread message. */
14
16
  const MessageLogs = ({ messageId }) => {
15
17
  const workflowRunIds = (0, hooks_1.useObservableState)([]);
16
18
  const showLogs = (0, hooks_1.useObservableState)(false);
@@ -92,11 +94,51 @@ const MessageLogs = ({ messageId }) => {
92
94
  };
93
95
  }, [messageId, workflowRunIds]);
94
96
  if (!workflowRunIds().length) {
95
- return false;
97
+ return (0, jsx_runtime_1.jsx)(ThreadAccessApprovals, { messageId: messageId });
96
98
  }
97
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["\u00A0", (0, jsx_runtime_1.jsx)(ShowLogsLink, { logIds: logIds, showLogs: showLogs, runs: runs }), showLogs() && ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(AllMessageLogs, { logIds: logIds, runs: runs }) }))] }));
99
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: ["\u00A0", (0, jsx_runtime_1.jsx)(ShowLogsLink, { logIds: logIds, showLogs: showLogs, runs: runs }), (0, jsx_runtime_1.jsx)(ThreadAccessApprovals, { messageId: messageId }), showLogs() && ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(AllMessageLogs, { logIds: logIds, runs: runs }) }))] }));
98
100
  };
99
101
  exports.MessageLogs = MessageLogs;
102
+ const ThreadAccessApprovals = ({ messageId }) => {
103
+ const [approvals] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)([]));
104
+ (0, hooks_1.useObservable)(approvals);
105
+ (0, react_1.useEffect)(() => {
106
+ let disposed = false;
107
+ let unsubscribe;
108
+ function setRows(rows) {
109
+ approvals(rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()));
110
+ }
111
+ async function subscribeToContext() {
112
+ const userContext = await (0, peers_sdk_1.getUserContext)();
113
+ if (disposed)
114
+ return;
115
+ const dataContext = userContext.defaultDataContext();
116
+ const approvalsTable = (0, peers_sdk_1.ToolAccessApprovals)(dataContext);
117
+ const sub = approvalsTable.dataChanged.subscribe((evt) => {
118
+ const row = evt.dataObject;
119
+ if (disposed || row.threadMessageId !== messageId)
120
+ return;
121
+ const next = approvals().filter((item) => item.toolAccessApprovalId !== row.toolAccessApprovalId);
122
+ if (evt.op !== "delete")
123
+ next.push(row);
124
+ setRows(next);
125
+ });
126
+ unsubscribe = () => sub.unsubscribe();
127
+ const rows = await approvalsTable.list({ threadMessageId: messageId });
128
+ if (!disposed)
129
+ setRows(rows);
130
+ }
131
+ void subscribeToContext();
132
+ return () => {
133
+ disposed = true;
134
+ unsubscribe?.();
135
+ };
136
+ }, [messageId, approvals]);
137
+ if (!approvals().length) {
138
+ return false;
139
+ }
140
+ return ((0, jsx_runtime_1.jsx)("div", { children: approvals().map((approval) => ((0, jsx_runtime_1.jsx)(tool_access_approval_card_1.ToolAccessApprovalCard, { approval: approval }, approval.toolAccessApprovalId))) }));
141
+ };
100
142
  const ShowLogsLink = (props) => {
101
143
  const { showLogs, logIds, runs } = props;
102
144
  (0, hooks_1.useObservable)(logIds);
@@ -0,0 +1,13 @@
1
+ import { type IToolAccessApproval } from "@peers-app/peers-sdk";
2
+ /** Props for {@link ToolAccessApprovalCard}. */
3
+ export interface ToolAccessApprovalCardProps {
4
+ /** Approval or grant row to render. */
5
+ approval: IToolAccessApproval;
6
+ }
7
+ /**
8
+ * Inline thread card for a pending or decided tool-access approval.
9
+ *
10
+ * Uses Bootstrap theme tokens (`bg-body-*`, `text-body-*`) so the card follows
11
+ * light and dark `data-bs-theme` instead of a hardcoded `bg-light` panel.
12
+ */
13
+ export declare function ToolAccessApprovalCard(props: ToolAccessApprovalCardProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ToolAccessApprovalCard = ToolAccessApprovalCard;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
6
+ const react_1 = require("react");
7
+ const hooks_1 = require("../../hooks");
8
+ const tool_access_ipc_1 = require("../../screens/tools/tool-access-ipc");
9
+ const GRANT_TTL_MINUTES = peers_sdk_1.TOOL_ACCESS_GRANT_TTL_MS / (60 * 1000);
10
+ /**
11
+ * Inline thread card for a pending or decided tool-access approval.
12
+ *
13
+ * Uses Bootstrap theme tokens (`bg-body-*`, `text-body-*`) so the card follows
14
+ * light and dark `data-bs-theme` instead of a hardcoded `bg-light` panel.
15
+ */
16
+ function ToolAccessApprovalCard(props) {
17
+ const { approval } = props;
18
+ const [busy, setBusy] = (0, react_1.useState)(false);
19
+ const [error, setError] = (0, react_1.useState)();
20
+ const userLevel = (0, hooks_1.usePromise)(async () => {
21
+ const userContext = await (0, peers_sdk_1.getUserContext)();
22
+ return (0, peers_sdk_1.getCurrentUserAccessLevel)(userContext.getDataContext(approval.dataContextId));
23
+ }, (0, peers_sdk_1.recordAccessLevel)({ accessLevel: 100 }), [approval.dataContextId]);
24
+ const required = approval.requiredAccessLevel;
25
+ const current = userLevel ?? 100;
26
+ const pending = approval.status === peers_sdk_1.ToolAccessApprovalStatus.pending;
27
+ const expired = approval.expiresAt.getTime() <= Date.now();
28
+ const argsPreview = meaningfulArgsPreview(approval.argsPreview);
29
+ const statusStyle = statusAppearance(approval.status, expired);
30
+ async function decide(scope, reject = false) {
31
+ setBusy(true);
32
+ setError(undefined);
33
+ try {
34
+ const consumer = await (0, tool_access_ipc_1.getToolAccessControlConsumer)(approval.dataContextId);
35
+ if (reject) {
36
+ await consumer.tools.reject({
37
+ approvalId: approval.toolAccessApprovalId,
38
+ nonce: approval.nonce,
39
+ });
40
+ }
41
+ else {
42
+ await consumer.tools.approve({
43
+ approvalId: approval.toolAccessApprovalId,
44
+ nonce: approval.nonce,
45
+ scope,
46
+ });
47
+ }
48
+ }
49
+ catch (err) {
50
+ setError(String(err));
51
+ }
52
+ finally {
53
+ setBusy(false);
54
+ }
55
+ }
56
+ return ((0, jsx_runtime_1.jsx)("div", { className: "card my-2 bg-body-secondary border-secondary-subtle", "data-testid": "tool-access-approval-card", children: (0, jsx_runtime_1.jsx)("div", { className: "card-body py-2 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-start gap-2", children: [(0, jsx_runtime_1.jsx)("i", { className: `bi ${statusStyle.icon} ${statusStyle.iconClass} mt-1`, "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1 min-w-0", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold text-body", children: pending ? "Approval required" : `Access ${approval.status}` }), (0, jsx_runtime_1.jsxs)("div", { className: "small text-body", children: [(0, jsx_runtime_1.jsx)("code", { className: "text-body", children: approval.toolName }), " needs", " ", (0, peers_sdk_1.getAccessLevelLabel)(required), " (", required, "). You have ", (0, peers_sdk_1.getAccessLevelLabel)(current), " ", "(", current, ")."] }), argsPreview && ((0, jsx_runtime_1.jsx)("pre", { className: "small mb-2 mt-2 p-2 bg-body-tertiary border rounded text-body", style: { whiteSpace: "pre-wrap" }, children: argsPreview })), (0, jsx_runtime_1.jsxs)("div", { className: "text-body-secondary small mt-1", children: ["Expires ", approval.expiresAt.toLocaleTimeString(), expired ? " (expired)" : ""] }), approval.resultSummary && ((0, jsx_runtime_1.jsx)("div", { className: "small text-body mt-1", children: approval.resultSummary })), error && (0, jsx_runtime_1.jsx)("div", { className: "text-danger small mt-1", children: error }), pending && !expired && ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex flex-wrap gap-1 mt-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-primary", disabled: busy, onClick: () => decide("once"), children: "Allow once" }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-secondary", disabled: busy, onClick: () => decide("thread"), children: ["Allow in thread (", GRANT_TTL_MINUTES, " min)"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-secondary", disabled: busy, onClick: () => decide("context"), children: ["Allow in this context (", GRANT_TTL_MINUTES, " min)"] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-danger", disabled: busy, onClick: () => decide(undefined, true), children: "Deny" })] }))] })] }) }) }));
57
+ }
58
+ function meaningfulArgsPreview(preview) {
59
+ const trimmed = preview?.trim();
60
+ if (!trimmed || trimmed === "{}" || trimmed === "[]") {
61
+ return undefined;
62
+ }
63
+ return trimmed;
64
+ }
65
+ function statusAppearance(status, expired) {
66
+ if (status === peers_sdk_1.ToolAccessApprovalStatus.pending && expired) {
67
+ return { icon: "bi-shield-x", iconClass: "text-body-secondary" };
68
+ }
69
+ switch (status) {
70
+ case peers_sdk_1.ToolAccessApprovalStatus.pending:
71
+ return { icon: "bi-shield-lock", iconClass: "text-warning" };
72
+ case peers_sdk_1.ToolAccessApprovalStatus.approved:
73
+ case peers_sdk_1.ToolAccessApprovalStatus.executed:
74
+ return { icon: "bi-shield-check", iconClass: "text-success" };
75
+ case peers_sdk_1.ToolAccessApprovalStatus.rejected:
76
+ case peers_sdk_1.ToolAccessApprovalStatus.failed:
77
+ return { icon: "bi-shield-x", iconClass: "text-danger" };
78
+ default:
79
+ return { icon: "bi-shield", iconClass: "text-body-secondary" };
80
+ }
81
+ }
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ const react_1 = require("@testing-library/react");
6
+ const tool_access_approval_card_1 = require("./tool-access-approval-card");
7
+ const approve = jest.fn(async () => ({ result: { status: "executed" } }));
8
+ const reject = jest.fn(async () => ({ result: { status: "rejected" } }));
9
+ const getDataContext = jest.fn((dataContextId) => ({ dataContextId }));
10
+ const getToolAccessControlConsumer = jest.fn(async (_dataContextId) => ({
11
+ tools: { approve, reject },
12
+ }));
13
+ jest.mock("../../screens/tools/tool-access-ipc", () => ({
14
+ getToolAccessControlConsumer: (dataContextId) => getToolAccessControlConsumer(dataContextId),
15
+ }));
16
+ jest.mock("@peers-app/peers-sdk", () => {
17
+ const actual = jest.requireActual("@peers-app/peers-sdk");
18
+ return {
19
+ ...actual,
20
+ getUserContext: async () => ({
21
+ getDataContext,
22
+ }),
23
+ getCurrentUserAccessLevel: async () => actual.AccessLevel.Self,
24
+ };
25
+ });
26
+ const pending = {
27
+ toolAccessApprovalId: "00testapproval0000000001",
28
+ userId: "00testuser000000000000001",
29
+ assistantId: "00testassistant000000001",
30
+ toolId: "00testtooladmin00000000",
31
+ toolName: "queryDatabase",
32
+ dataContextId: "00testdatacontext00000001",
33
+ argsHash: "hash",
34
+ argsPreview: '{"sql":"select 1"}',
35
+ frozenArgs: { sql: "select 1" },
36
+ requiredAccessLevel: peers_sdk_1.AccessLevel.Owner,
37
+ scope: peers_sdk_1.ToolAccessApprovalScope.once,
38
+ status: peers_sdk_1.ToolAccessApprovalStatus.pending,
39
+ nonce: "nonce-from-table",
40
+ expiresAt: new Date(Date.now() + 60_000),
41
+ createdAt: new Date(),
42
+ };
43
+ describe("ToolAccessApprovalCard", () => {
44
+ beforeEach(() => {
45
+ approve.mockClear();
46
+ reject.mockClear();
47
+ getDataContext.mockClear();
48
+ getToolAccessControlConsumer.mockClear();
49
+ });
50
+ it("renders a pending card with actions", () => {
51
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tool_access_approval_card_1.ToolAccessApprovalCard, { approval: pending }));
52
+ expect(react_1.screen.getByText("Approval required")).toBeInTheDocument();
53
+ expect(react_1.screen.getByText("queryDatabase")).toBeInTheDocument();
54
+ expect(react_1.screen.getByText(/Owner \(80\)/)).toBeInTheDocument();
55
+ expect(react_1.screen.getByRole("button", { name: "Allow once" })).toBeInTheDocument();
56
+ expect(react_1.screen.getByRole("button", { name: "Allow in thread (15 min)" })).toBeInTheDocument();
57
+ expect(react_1.screen.getByRole("button", { name: "Allow in this context (15 min)" })).toBeInTheDocument();
58
+ expect(react_1.screen.getByRole("button", { name: "Deny" })).toBeInTheDocument();
59
+ });
60
+ it("evaluates the user role in the approval context", async () => {
61
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tool_access_approval_card_1.ToolAccessApprovalCard, { approval: pending }));
62
+ await (0, react_1.waitFor)(() => {
63
+ expect(getDataContext).toHaveBeenCalledWith(pending.dataContextId);
64
+ });
65
+ });
66
+ it("approves once using the table nonce", async () => {
67
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tool_access_approval_card_1.ToolAccessApprovalCard, { approval: pending }));
68
+ react_1.fireEvent.click(react_1.screen.getByRole("button", { name: "Allow once" }));
69
+ await (0, react_1.waitFor)(() => {
70
+ expect(getToolAccessControlConsumer).toHaveBeenCalledWith(pending.dataContextId);
71
+ expect(approve).toHaveBeenCalledWith({
72
+ approvalId: pending.toolAccessApprovalId,
73
+ nonce: "nonce-from-table",
74
+ scope: "once",
75
+ });
76
+ });
77
+ });
78
+ it("denies using the table nonce", async () => {
79
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tool_access_approval_card_1.ToolAccessApprovalCard, { approval: pending }));
80
+ react_1.fireEvent.click(react_1.screen.getByRole("button", { name: "Deny" }));
81
+ await (0, react_1.waitFor)(() => {
82
+ expect(reject).toHaveBeenCalledWith({
83
+ approvalId: pending.toolAccessApprovalId,
84
+ nonce: "nonce-from-table",
85
+ });
86
+ });
87
+ });
88
+ it("hides empty argument previews", () => {
89
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tool_access_approval_card_1.ToolAccessApprovalCard, { approval: { ...pending, argsPreview: "{}", frozenArgs: {} } }));
90
+ expect(react_1.screen.queryByText("{}")).not.toBeInTheDocument();
91
+ });
92
+ it("shows a decided card without actions", () => {
93
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tool_access_approval_card_1.ToolAccessApprovalCard, { approval: {
94
+ ...pending,
95
+ status: peers_sdk_1.ToolAccessApprovalStatus.rejected,
96
+ resultSummary: "Rejected by user",
97
+ } }));
98
+ expect(react_1.screen.getByText("Access rejected")).toBeInTheDocument();
99
+ expect(react_1.screen.getByText("Rejected by user")).toBeInTheDocument();
100
+ expect(react_1.screen.queryByRole("button", { name: "Allow once" })).not.toBeInTheDocument();
101
+ });
102
+ });
@@ -6,5 +6,6 @@ interface AssistantInfoProps {
6
6
  onSetPrimary: (checked: boolean) => void;
7
7
  onSetGroupPrimary: (checked: boolean) => void;
8
8
  }
9
+ /** Info tab for an Assistant, including its access-level policy. */
9
10
  export declare const AssistantInfo: (props: AssistantInfoProps) => import("react/jsx-runtime").JSX.Element;
10
11
  export {};
@@ -2,10 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AssistantInfo = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const access_level_field_1 = require("../../components/access-level-field");
5
6
  const input_1 = require("../../components/input");
6
7
  const editor_inline_1 = require("../../components/markdown-editor/editor-inline");
8
+ /** Info tab for an Assistant, including its access-level policy. */
7
9
  const AssistantInfo = (props) => {
8
10
  const { assistant, isPrimaryAssistant, isGroupPrimaryAssistant, onSetPrimary, onSetGroupPrimary, } = props;
9
- return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("small", { children: "Name:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: assistant.qs.name, className: "form-control mb-3 p-0 ps-2", placeholder: "Tool name", title: "Tool name" }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "isPrimary", className: "form-label small mt-2 me-2", children: "My Default Assistant:" }), (0, jsx_runtime_1.jsx)("input", { type: "checkbox", id: "isPrimary", checked: isPrimaryAssistant, onChange: (e) => onSetPrimary(e.target.checked) })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "isGroupPrimary", className: "form-label small mt-2 me-2", children: "Group Default Assistant:" }), (0, jsx_runtime_1.jsx)("input", { type: "checkbox", id: "isGroupPrimary", checked: isGroupPrimaryAssistant, onChange: (e) => onSetGroupPrimary(e.target.checked) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("small", { children: "Description:" }), (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: assistant.qs.description })] })] }));
11
+ return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("small", { children: "Name:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: assistant.qs.name, className: "form-control mb-3 p-0 ps-2", placeholder: "Tool name", title: "Tool name" }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "isPrimary", className: "form-label small mt-2 me-2", children: "My Default Assistant:" }), (0, jsx_runtime_1.jsx)("input", { type: "checkbox", id: "isPrimary", checked: isPrimaryAssistant, onChange: (e) => onSetPrimary(e.target.checked) })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { htmlFor: "isGroupPrimary", className: "form-label small mt-2 me-2", children: "Group Default Assistant:" }), (0, jsx_runtime_1.jsx)("input", { type: "checkbox", id: "isGroupPrimary", checked: isGroupPrimaryAssistant, onChange: (e) => onSetGroupPrimary(e.target.checked) })] }), (0, jsx_runtime_1.jsx)(access_level_field_1.AccessLevelField, { value: assistant.qs.accessLevel }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-2", children: [(0, jsx_runtime_1.jsx)("small", { children: "Description:" }), (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: assistant.qs.description })] })] }));
10
12
  };
11
13
  exports.AssistantInfo = AssistantInfo;
@@ -1 +1,2 @@
1
+ /** Searchable Assistant list with access-level visibility. */
1
2
  export declare const AssistantList: () => import("react/jsx-runtime").JSX.Element;
@@ -10,11 +10,16 @@ const loading_indicator_1 = require("../../components/loading-indicator");
10
10
  const markdown_with_mentions_1 = require("../../components/markdown-with-mentions");
11
11
  const globals_1 = require("../../globals");
12
12
  const hooks_1 = require("../../hooks");
13
+ /** Searchable Assistant list with access-level visibility. */
13
14
  const AssistantList = () => {
14
15
  const [searchTextObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)(""));
15
16
  const [searchText] = (0, hooks_1.useObservable)(searchTextObs);
16
17
  const [cursorObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)());
17
18
  const [cursorId] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)((0, peers_sdk_1.newid)()));
19
+ const userLevel = (0, hooks_1.usePromise)(async () => {
20
+ const userContext = await (0, peers_sdk_1.getUserContext)();
21
+ return (0, peers_sdk_1.getCurrentUserAccessLevel)(userContext.defaultDataContext());
22
+ }, 100);
18
23
  async function newCursor() {
19
24
  let cursor;
20
25
  const _searchText = searchText.trim();
@@ -66,7 +71,9 @@ const AssistantList = () => {
66
71
  }
67
72
  return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid", children: [(0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", style: { marginBottom: "10px" }, placeholder: "Search or create new assistant", autoFocus: !!(0, globals_1.isDesktop)(), onKeyUp: (evt) => searchSubmit(evt) }), (0, jsx_runtime_1.jsx)("div", { className: "peers-list-container", children: (0, jsx_runtime_1.jsx)(lazy_list_1.LazyList, { loadMore: loadMore, scrollThreshold: 0.6, renderItems: (assistants) => {
68
73
  return assistants.map((assistant) => {
69
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid pb-4", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-fill-gear" }), "\u00A0", (0, jsx_runtime_1.jsx)("a", { href: `#assistants/${assistant.assistantId}`, children: assistant.name }), (0, jsx_runtime_1.jsx)("div", { style: { paddingLeft: "20px" }, children: (0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: assistant.description ?? "" }) })] }, assistant.assistantId));
74
+ const required = (0, peers_sdk_1.recordAccessLevel)(assistant);
75
+ const allowed = (0, peers_sdk_1.canInvokeAssistant)(userLevel ?? 100, required);
76
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid pb-4", style: allowed ? undefined : { opacity: 0.65 }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-fill-gear" }), "\u00A0", (0, jsx_runtime_1.jsx)("a", { href: `#assistants/${assistant.assistantId}`, children: assistant.name }), !allowed && ((0, jsx_runtime_1.jsxs)("span", { className: "badge text-bg-secondary ms-2", children: ["Requires ", (0, peers_sdk_1.getAccessLevelLabel)(required), " (", required, ")"] })), (0, jsx_runtime_1.jsxs)("div", { style: { paddingLeft: "20px" }, children: [(0, jsx_runtime_1.jsx)(markdown_with_mentions_1.MarkdownWithMentions, { content: assistant.description ?? "" }), !allowed && ((0, jsx_runtime_1.jsxs)("div", { className: "small text-muted", children: ["You cannot invoke this Assistant with your current access (", (0, peers_sdk_1.getAccessLevelLabel)(userLevel ?? 100), ")."] }))] })] }, assistant.assistantId));
70
77
  });
71
78
  }, loadingIndicator: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 }, children: (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}) }), endOfList: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 } }) }, cursorId()) })] }));
72
79
  };
@@ -0,0 +1,5 @@
1
+ import { type IToolAccessControlConsumer } from "@peers-app/peers-sdk";
2
+ /**
3
+ * Return a typed local-host consumer for Tool Access Control in `dataContextId`.
4
+ */
5
+ export declare function getToolAccessControlConsumer(dataContextId: string): Promise<IToolAccessControlConsumer>;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getToolAccessControlConsumer = getToolAccessControlConsumer;
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ const HOST_TRANSPORT_TIMEOUT_MS = 10_000;
6
+ const cachedByContext = new Map();
7
+ const pendingByContext = new Map();
8
+ function disposeCachedConsumers() {
9
+ for (const cached of cachedByContext.values()) {
10
+ cached.consumer.dispose();
11
+ }
12
+ cachedByContext.clear();
13
+ }
14
+ (0, peers_sdk_1.subscribeHostContractTransport)((transport) => {
15
+ if ([...cachedByContext.values()].some((cached) => cached.transport !== transport)) {
16
+ disposeCachedConsumers();
17
+ pendingByContext.clear();
18
+ }
19
+ }, { emitCurrent: false });
20
+ async function loadConsumer(transport, dataContextId) {
21
+ cachedByContext.get(dataContextId)?.consumer.dispose();
22
+ cachedByContext.delete(dataContextId);
23
+ const consumer = (0, peers_sdk_1.createContractConsumer)((0, peers_sdk_1.getToolAccessControlContractDefinition)(), transport, {
24
+ dataContextId,
25
+ });
26
+ try {
27
+ await consumer.loadingPromise;
28
+ if ((0, peers_sdk_1.getHostContractTransport)() !== transport) {
29
+ consumer.dispose();
30
+ pendingByContext.delete(dataContextId);
31
+ return getToolAccessControlConsumer(dataContextId);
32
+ }
33
+ cachedByContext.set(dataContextId, { transport, consumer });
34
+ return consumer;
35
+ }
36
+ catch (error) {
37
+ consumer.dispose();
38
+ throw error;
39
+ }
40
+ }
41
+ /**
42
+ * Return a typed local-host consumer for Tool Access Control in `dataContextId`.
43
+ */
44
+ async function getToolAccessControlConsumer(dataContextId) {
45
+ const transport = (0, peers_sdk_1.getHostContractTransport)() ??
46
+ (await (0, peers_sdk_1.waitForHostContractTransport)({ timeoutMs: HOST_TRANSPORT_TIMEOUT_MS }));
47
+ const cached = cachedByContext.get(dataContextId);
48
+ if (cached?.transport === transport) {
49
+ return cached.consumer;
50
+ }
51
+ const pending = pendingByContext.get(dataContextId);
52
+ if (pending) {
53
+ return pending;
54
+ }
55
+ const loadPromise = loadConsumer(transport, dataContextId);
56
+ pendingByContext.set(dataContextId, loadPromise);
57
+ try {
58
+ return await loadPromise;
59
+ }
60
+ finally {
61
+ if (pendingByContext.get(dataContextId) === loadPromise) {
62
+ pendingByContext.delete(dataContextId);
63
+ }
64
+ }
65
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
4
+ const tool_access_ipc_1 = require("./tool-access-ipc");
5
+ function setupHost() {
6
+ const calls = [];
7
+ const [consumerTransport, providerTransport] = (0, peers_sdk_1.inProcessTransportPair)();
8
+ providerTransport.on(peers_sdk_1.CONTRACT_REQUEST_CHANNEL, (call) => {
9
+ calls.push(call);
10
+ if (call.member === "tool" && call.name === "approve") {
11
+ return { result: { status: "executed" } };
12
+ }
13
+ return undefined;
14
+ });
15
+ return {
16
+ calls,
17
+ dispose: (0, peers_sdk_1.registerHostContractTransport)(consumerTransport),
18
+ };
19
+ }
20
+ describe("Tool Access Control host consumers", () => {
21
+ it("caches per context and scopes calls to the requested context", async () => {
22
+ const host = setupHost();
23
+ const groupContextId = (0, peers_sdk_1.newid)();
24
+ const personalContextId = (0, peers_sdk_1.newid)();
25
+ const groupConsumer = await (0, tool_access_ipc_1.getToolAccessControlConsumer)(groupContextId);
26
+ expect(await (0, tool_access_ipc_1.getToolAccessControlConsumer)(groupContextId)).toBe(groupConsumer);
27
+ expect(await (0, tool_access_ipc_1.getToolAccessControlConsumer)(personalContextId)).not.toBe(groupConsumer);
28
+ await groupConsumer.tools.approve({
29
+ approvalId: (0, peers_sdk_1.newid)(),
30
+ nonce: (0, peers_sdk_1.newid)(),
31
+ scope: "once",
32
+ });
33
+ expect(host.calls).toContainEqual(expect.objectContaining({
34
+ dataContextId: groupContextId,
35
+ member: "tool",
36
+ name: "approve",
37
+ }));
38
+ host.dispose();
39
+ });
40
+ });
@@ -1,4 +1,5 @@
1
1
  import { type IDoc, type ITool } from "@peers-app/peers-sdk";
2
+ /** Info tab for a tool, including its required access level. */
2
3
  export declare const ToolInfo: (props: {
3
4
  tool: IDoc<ITool>;
4
5
  }) => import("react/jsx-runtime").JSX.Element;
@@ -4,9 +4,11 @@ exports.ToolInfo = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const peers_sdk_1 = require("@peers-app/peers-sdk");
6
6
  const react_1 = require("react");
7
+ const access_level_field_1 = require("../../components/access-level-field");
7
8
  const checkbox_1 = require("../../components/checkbox");
8
9
  const input_1 = require("../../components/input");
9
10
  const editor_inline_1 = require("../../components/markdown-editor/editor-inline");
11
+ /** Info tab for a tool, including its required access level. */
10
12
  const ToolInfo = (props) => {
11
13
  const { tool } = props;
12
14
  const [markdownUsageObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)(tool.usageDescription));
@@ -30,6 +32,6 @@ const ToolInfo = (props) => {
30
32
  sub3.dispose();
31
33
  };
32
34
  }, [tool, markdownDetailedObs, markdownUsageObs]);
33
- return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("small", { children: "Name:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: tool.qs.name, className: "form-control mb-3 p-0 ps-2", placeholder: "Tool name", title: "Tool name" }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "form-label small mt-2 me-2", children: "Is Assistant Runner:" }), (0, jsx_runtime_1.jsx)(checkbox_1.Checkbox, { className: "form-check-input mt-2", checked: tool.qs.isAssistantRunner })] }), (0, jsx_runtime_1.jsx)("small", { children: "Usage Description:" }), (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: markdownUsageObs }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("small", { children: "Detailed Description:" }), (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: markdownDetailedObs })] }));
35
+ return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("small", { children: "Name:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: tool.qs.name, className: "form-control mb-3 p-0 ps-2", placeholder: "Tool name", title: "Tool name" }), (0, jsx_runtime_1.jsx)(access_level_field_1.AccessLevelField, { value: tool.qs.accessLevel }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("label", { className: "form-label small mt-2 me-2", children: "Is Assistant Runner:" }), (0, jsx_runtime_1.jsx)(checkbox_1.Checkbox, { className: "form-check-input mt-2", checked: tool.qs.isAssistantRunner })] }), (0, jsx_runtime_1.jsx)("small", { children: "Usage Description:" }), (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: markdownUsageObs }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("small", { children: "Detailed Description:" }), (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: markdownDetailedObs })] }));
34
36
  };
35
37
  exports.ToolInfo = ToolInfo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.22.0",
31
+ "@peers-app/peers-sdk": "^0.23.0",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-react": "^7.24.1",
40
40
  "@babel/preset-typescript": "^7.27.1",
41
41
  "@biomejs/biome": "2.3.11",
42
- "@peers-app/peers-sdk": "0.22.0",
42
+ "@peers-app/peers-sdk": "0.23.0",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -0,0 +1,32 @@
1
+ import { observable } from "@peers-app/peers-sdk";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { AccessLevelField } from "./access-level-field";
4
+
5
+ describe("AccessLevelField", () => {
6
+ it("shows the named preset and current label", () => {
7
+ render(<AccessLevelField value={observable(40)} />);
8
+ expect(screen.getByRole("combobox")).toHaveValue("40");
9
+ expect(screen.getByText(/Unclassified records default to Self/)).toHaveTextContent(
10
+ "Writer (40)",
11
+ );
12
+ });
13
+
14
+ it("writes a named preset onto the observable", () => {
15
+ const value = observable(40);
16
+ render(<AccessLevelField value={value} />);
17
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "60" } });
18
+ expect(value()).toBe(60);
19
+ expect(screen.getByText(/Unclassified records default to Self/)).toHaveTextContent(
20
+ "Admin (60)",
21
+ );
22
+ });
23
+
24
+ it("edits a custom integer between named tiers", () => {
25
+ const value = observable(45);
26
+ render(<AccessLevelField value={value} />);
27
+ expect(screen.getByRole("combobox")).toHaveValue("custom");
28
+ fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "47" } });
29
+ expect(value()).toBe(47);
30
+ expect(screen.getByText(/Writer\+ \(47\)/)).toBeInTheDocument();
31
+ });
32
+ });