@peers-app/peers-ui 0.25.20 → 0.26.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 (41) hide show
  1. package/dist/mention-configs.d.ts +7 -0
  2. package/dist/mention-configs.js +8 -2
  3. package/dist/screens/assistants/assistant-config.d.ts +1 -0
  4. package/dist/screens/assistants/assistant-config.js +20 -3
  5. package/dist/screens/assistants/assistant-config.test.d.ts +1 -0
  6. package/dist/screens/assistants/assistant-config.test.js +57 -0
  7. package/dist/screens/settings/settings-page.js +1 -59
  8. package/dist/screens/settings/settings-page.test.d.ts +1 -0
  9. package/dist/screens/settings/settings-page.test.js +19 -0
  10. package/dist/screens/tools/tool-catalog-ipc.d.ts +118 -0
  11. package/dist/screens/tools/tool-catalog-ipc.js +48 -0
  12. package/dist/screens/tools/tool-catalog-ipc.test.d.ts +1 -0
  13. package/dist/screens/tools/tool-catalog-ipc.test.js +46 -0
  14. package/dist/screens/tools/tool-details.d.ts +3 -4
  15. package/dist/screens/tools/tool-details.js +14 -33
  16. package/dist/screens/tools/tool-info.d.ts +5 -4
  17. package/dist/screens/tools/tool-info.js +10 -30
  18. package/dist/screens/tools/tool-list.d.ts +1 -0
  19. package/dist/screens/tools/tool-list.js +2 -26
  20. package/dist/screens/tools/tool-schema.d.ts +5 -3
  21. package/dist/screens/tools/tool-schema.js +5 -14
  22. package/dist/ui-router/ui-loader.js +14 -27
  23. package/dist/ui-router/ui-loader.test.d.ts +1 -0
  24. package/dist/ui-router/ui-loader.test.js +91 -0
  25. package/package.json +3 -3
  26. package/src/mention-configs.ts +9 -5
  27. package/src/screens/assistants/assistant-config.test.tsx +54 -0
  28. package/src/screens/assistants/assistant-config.tsx +42 -11
  29. package/src/screens/settings/settings-page.test.tsx +24 -0
  30. package/src/screens/settings/settings-page.tsx +1 -255
  31. package/src/screens/tools/tool-catalog-ipc.test.ts +46 -0
  32. package/src/screens/tools/tool-catalog-ipc.ts +51 -0
  33. package/src/screens/tools/tool-details.tsx +18 -67
  34. package/src/screens/tools/tool-info.tsx +39 -53
  35. package/src/screens/tools/tool-list.tsx +4 -37
  36. package/src/screens/tools/tool-schema.tsx +40 -45
  37. package/src/ui-router/ui-loader.test.tsx +108 -0
  38. package/src/ui-router/ui-loader.tsx +15 -46
  39. package/dist/screens/tools/tool-code.d.ts +0 -4
  40. package/dist/screens/tools/tool-code.js +0 -28
  41. package/src/screens/tools/tool-code.tsx +0 -30
@@ -1,49 +1,44 @@
1
- import {
2
- type IDoc,
3
- type IOSchema,
4
- IOSchemaType,
5
- type ITool,
6
- type Observable,
7
- } from "@peers-app/peers-sdk";
8
- import { IOSchemaEditor } from "../../components/io-schema";
9
- import { useObservable } from "../../hooks";
10
-
11
- export const ToolSchema = (props: { tool: IDoc<ITool> }) => {
12
- const { tool } = props;
13
-
14
- useObservable(tool.qs.isAssistantRunner);
15
-
16
- if (tool.isAssistantRunner && !tool.configSchema) {
17
- tool.configSchema = {
18
- type: IOSchemaType.complex,
19
- fields: [],
20
- };
21
- }
1
+ import type { IOSchema, ITool, IToolCatalogEntry } from "@peers-app/peers-sdk";
22
2
 
3
+ function FieldTable({ label, schema }: { label: string; schema?: IOSchema }) {
23
4
  return (
24
- <div>
25
- {tool.isAssistantRunner && (
26
- <>
27
- <span style={{ width: "130px", display: "inline-block" }} className="fw-bold">
28
- Config Schema:
29
- </span>
30
- <IOSchemaEditor
31
- ioSchema={tool.qs.configSchema as Observable<IOSchema>}
32
- simpleValueName="input"
33
- />
34
- <br />
35
- </>
5
+ <section className="mb-4">
6
+ <h6>{label}</h6>
7
+ {!schema?.fields.length ? (
8
+ <p>No fields</p>
9
+ ) : (
10
+ <table className="table table-sm">
11
+ <thead>
12
+ <tr>
13
+ <th>Name</th>
14
+ <th>Type</th>
15
+ <th>Required</th>
16
+ <th>Description</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ {schema.fields.map((field) => (
21
+ <tr key={field.name}>
22
+ <td>{field.name}</td>
23
+ <td>
24
+ {field.type}
25
+ {field.isArray ? "[]" : ""}
26
+ </td>
27
+ <td>{field.optional ? "No" : "Yes"}</td>
28
+ <td>{field.description}</td>
29
+ </tr>
30
+ ))}
31
+ </tbody>
32
+ </table>
36
33
  )}
37
-
38
- <span style={{ width: "130px", display: "inline-block" }} className="fw-bold">
39
- Input Schema:
40
- </span>
41
- <IOSchemaEditor ioSchema={tool.qs.inputSchema} simpleValueName="input" />
42
-
43
- <span style={{ width: "130px", display: "inline-block" }} className="mt-4 fw-bold">
44
- Output Schema:
45
- </span>
46
- <IOSchemaEditor ioSchema={tool.qs.outputSchema} simpleValueName="output" />
47
- </div>
34
+ </section>
48
35
  );
49
- };
36
+ }
37
+ /** Read-only contract field tables, plus persisted assistant runner configuration. */
38
+ export const ToolSchema = ({ tool, entry }: { tool: ITool; entry?: IToolCatalogEntry }) => (
39
+ <div>
40
+ {tool.isAssistantRunner && <FieldTable label="Config Schema" schema={tool.configSchema} />}
41
+ <FieldTable label="Input Schema" schema={entry?.inputSchema ?? tool.inputSchema} />
42
+ <FieldTable label="Output Schema" schema={entry?.outputSchema ?? tool.outputSchema} />
43
+ </div>
44
+ );
@@ -0,0 +1,108 @@
1
+ import { act, fireEvent, render, screen } from "@testing-library/react";
2
+ import { useEffect, useState, useSyncExternalStore } from "react";
3
+
4
+ const mockPackages: Record<string, unknown>[] = [];
5
+ const mockGetFileContents = jest.fn();
6
+ jest.mock("@peers-app/peers-sdk", () => ({
7
+ debounceByArgs: (fn: unknown) => fn,
8
+ getUserContext: () => new Promise(() => {}),
9
+ packagePrefsVar: () => Object.assign(() => ({}), { loadingPromise: Promise.resolve() }),
10
+ PackageVersions: () => ({ get: async () => ({ uiBundleFileId: "bundle" }) }),
11
+ rpcServerCalls: { getFileContents: (...args: unknown[]) => mockGetFileContents(...args) },
12
+ }));
13
+ jest.mock("../globals", () => ({ mainContentPath: () => "", packageReloaded: () => 0 }));
14
+ jest.mock("../hooks", () => ({ useObservable: (value: () => unknown) => [value()] }));
15
+ jest.mock("../ui-defaults", () => ({}));
16
+ jest.mock("../components/loading-indicator", () => ({
17
+ LoadingIndicator: () => <p>Loading bundle</p>,
18
+ }));
19
+ jest.mock("./routes-loader", () => ({ allPackages: () => mockPackages }));
20
+
21
+ import { registerInternalPeersUI, UIRouter } from "./ui-loader";
22
+
23
+ test.each([
24
+ false,
25
+ true,
26
+ ])("query changes retain screen state and props (preloaded: %s)", async (preloaded) => {
27
+ const packageId = `package-${preloaded}`;
28
+ const peersUIId = `ui-${preloaded}`;
29
+ const base = `package-nav/${packageId}/app`;
30
+ let path = `${base}?jobId=first`;
31
+ const listeners = new Set<() => void>();
32
+ let mounts = 0;
33
+ let unmounts = 0;
34
+ function Content({ caller }: { caller: string }) {
35
+ const [draft, setDraft] = useState("");
36
+ const currentPath = useSyncExternalStore(
37
+ (listener) => {
38
+ listeners.add(listener);
39
+ return () => {
40
+ listeners.delete(listener);
41
+ };
42
+ },
43
+ () => path,
44
+ );
45
+ useEffect(() => {
46
+ mounts++;
47
+ return () => {
48
+ unmounts++;
49
+ };
50
+ }, []);
51
+ return (
52
+ <>
53
+ <p>{caller}</p>
54
+ <p>{currentPath}</p>
55
+ <input
56
+ aria-label="Draft"
57
+ value={draft}
58
+ onChange={(event) => setDraft(event.target.value)}
59
+ />
60
+ </>
61
+ );
62
+ }
63
+ const schema = { parse: (value: unknown) => value, safeParse: () => ({ success: true }) };
64
+ const ui = { peersUIId, content: Content, propsSchema: schema };
65
+ const routeWindow = window as unknown as {
66
+ registerPeersUIRoute: (route: object) => void;
67
+ testUI?: object;
68
+ };
69
+ routeWindow.registerPeersUIRoute({ packageId, peersUIId, path: base });
70
+ mockPackages.length = 0;
71
+ mockGetFileContents.mockReset();
72
+ let releaseBundle!: (code: string) => void;
73
+ if (preloaded) {
74
+ await registerInternalPeersUI({ component: Content, peersUIId, propsSchema: schema });
75
+ } else {
76
+ mockPackages.push({ packageId, name: "Test package", activePackageVersionId: "version" });
77
+ routeWindow.testUI = ui;
78
+ mockGetFileContents.mockImplementation(
79
+ () =>
80
+ new Promise<string>((resolve) => {
81
+ releaseBundle = resolve;
82
+ }),
83
+ );
84
+ }
85
+ const view = render(<UIRouter path={path} props={{ caller: "Caller props" }} />);
86
+ if (!preloaded) {
87
+ expect(await screen.findByText("Loading bundle")).toBeInTheDocument();
88
+ await act(async () => {
89
+ releaseBundle("exportUIs({uis: [window.testUI]});");
90
+ });
91
+ }
92
+ expect(await screen.findByText("Caller props")).toBeInTheDocument();
93
+ fireEvent.change(screen.getByLabelText("Draft"), { target: { value: "Unsaved edits" } });
94
+ await act(async () => {
95
+ path = `${base}?jobId=second`;
96
+ for (const listener of listeners) listener();
97
+ view.rerender(<UIRouter path={path} props={{ caller: "Caller props" }} />);
98
+ });
99
+ expect(screen.getByLabelText("Draft")).toHaveValue("Unsaved edits");
100
+ expect(screen.getByText(path)).toBeInTheDocument();
101
+ expect({ mounts, unmounts, loads: mockGetFileContents.mock.calls.length }).toEqual({
102
+ mounts: 1,
103
+ unmounts: 0,
104
+ loads: preloaded ? 0 : 1,
105
+ });
106
+ view.unmount();
107
+ delete routeWindow.testUI;
108
+ });
@@ -239,7 +239,7 @@ export const UIRouter = (props: IProps) => {
239
239
  return undefined;
240
240
  }
241
241
 
242
- return UILoader({ peersUIId: matchingRoute.peersUIId, props: props.props });
242
+ return <UILoader peersUIId={matchingRoute.peersUIId} props={props.props} />;
243
243
  };
244
244
 
245
245
  const UILoader = (args: { peersUIId: string; props?: Record<string, unknown> }) => {
@@ -249,40 +249,9 @@ const UILoader = (args: { peersUIId: string; props?: Record<string, unknown> })
249
249
  const { props, peersUIId } = args;
250
250
  const [lastReloadTime] = useObservable(packageReloaded);
251
251
  props.__lastPackageReload = lastReloadTime;
252
- // check if peersUIId maps to an in-memory react component
253
- const peersUI = peersUIs[peersUIId];
254
- if (peersUI?.content) {
255
- const parseResult = peersUI.propsSchema.safeParse(props);
256
- if (!parseResult.success) {
257
- console.error("props did not match schema", {
258
- props,
259
- schema: peersUI.propsSchema,
260
- errors: parseResult.error,
261
- });
262
- return (
263
- <div>
264
- <pre>
265
- Peers UI could not be loaded <br />
266
- peersUIId: {peersUI.peersUIId} <br />
267
- Component name: {peersUI.content?.name} <br />
268
- Props did not match schema <br />
269
- Errors: {JSON.stringify(parseResult.error, null, 2)} <br />
270
- <br />
271
- Props {JSON.stringify(toJSON(props), null, 2)}
272
- <br />
273
- </pre>
274
- </div>
275
- );
276
- }
277
- const Component = peersUI.content as React.ComponentType<Record<string, unknown>>;
278
- return (
279
- <UIErrorBoundary peersUIId={peersUIId} componentName={peersUI.content?.name}>
280
- <Component {...args.props} />
281
- </UIErrorBoundary>
282
- );
283
- }
284
-
285
- return <UIAsyncLoader peersUIId={peersUIId} props={props} />;
252
+ // Keep the same loader ancestor after the bundle enters the cache. Replacing it
253
+ // on a path change would unmount the screen before its navigation guard can run.
254
+ return <UIAsyncLoader key={peersUIId} peersUIId={peersUIId} props={props} />;
286
255
  };
287
256
 
288
257
  const uiLoadingPromises: Record<string, Promise<void>> = {};
@@ -379,6 +348,7 @@ function loadUIBundle(pkg: IPackage, forceRefresh?: boolean) {
379
348
 
380
349
  const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown> }) => {
381
350
  const { peersUIId } = props;
351
+ const peersUI = peersUIs[peersUIId];
382
352
  const [loaded, setLoaded] = React.useState(false);
383
353
  const [loadError, setLoadError] = React.useState<Error | null>(null);
384
354
 
@@ -389,7 +359,7 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
389
359
  const pkg = allPackages().find((p) => p.packageId === uiPackageId);
390
360
 
391
361
  useEffect(() => {
392
- if (!pkg) return;
362
+ if (!pkg || peersUIs[peersUIId]?.content) return;
393
363
  const pkgRef = pkg;
394
364
  setLoadError(null);
395
365
  loadUIBundle(pkgRef)
@@ -398,13 +368,13 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
398
368
  console.error(`Failed to load UI bundle for package ${pkgRef.name}:`, error);
399
369
  setLoadError(error);
400
370
  });
401
- }, [pkg?.packageId, pkg?.name, pkg]);
371
+ }, [peersUIId, pkg?.packageId, pkg?.name, pkg]);
402
372
 
403
- if (!pkg) {
373
+ if (!peersUI?.content && !pkg) {
404
374
  return <div>Could not find package that owns peersUIId {peersUIId}</div>;
405
375
  }
406
376
 
407
- if (loadError) {
377
+ if (!peersUI?.content && loadError) {
408
378
  return (
409
379
  <div
410
380
  style={{
@@ -423,10 +393,10 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
423
393
  <strong>Peers UI ID:</strong> {peersUIId}
424
394
  </div>
425
395
  <div>
426
- <strong>Package:</strong> {pkg.name}
396
+ <strong>Package:</strong> {pkg?.name}
427
397
  </div>
428
398
  <div>
429
- <strong>Package ID:</strong> {pkg.packageId}
399
+ <strong>Package ID:</strong> {pkg?.packageId}
430
400
  </div>
431
401
  </div>
432
402
 
@@ -484,20 +454,19 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
484
454
  );
485
455
  }
486
456
 
487
- if (!loaded) {
457
+ if (!peersUI?.content && !loaded) {
488
458
  return <LoadingIndicator />;
489
459
  }
490
460
 
491
- const peersUI = peersUIs[peersUIId];
492
461
  if (peersUI?.content) {
493
- const _props = { ...props, uiVar: undefined };
462
+ const _props = props.props;
494
463
  try {
495
464
  peersUI.propsSchema.parse(_props);
496
465
  const Component = peersUI.content as React.ComponentType<Record<string, unknown>>;
497
466
  return (
498
467
  <UIErrorBoundary
499
468
  peersUIId={peersUIId}
500
- packageName={pkg.name}
469
+ packageName={pkg?.name}
501
470
  componentName={peersUI.content?.name}
502
471
  >
503
472
  <Component {..._props} />
@@ -522,7 +491,7 @@ const UIAsyncLoader = (props: { peersUIId: string; props: Record<string, unknown
522
491
  <strong>Peers UI ID:</strong> {peersUIId}
523
492
  </div>
524
493
  <div>
525
- <strong>Package:</strong> {pkg.name}
494
+ <strong>Package:</strong> {pkg?.name}
526
495
  </div>
527
496
  <div>
528
497
  <strong>Component:</strong> {peersUI.content?.name}
@@ -1,4 +0,0 @@
1
- import { type IDoc, type ITool } from "@peers-app/peers-sdk";
2
- export declare const ToolCode: (props: {
3
- tool: IDoc<ITool>;
4
- }) => import("react/jsx-runtime").JSX.Element;
@@ -1,28 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ToolCode = void 0;
4
- const jsx_runtime_1 = require("react/jsx-runtime");
5
- const peers_sdk_1 = require("@peers-app/peers-sdk");
6
- const editor_inline_1 = require("../../components/markdown-editor/editor-inline");
7
- const ToolCode = (props) => {
8
- const { tool } = props;
9
- let code = tool.qs.code() ?? "";
10
- const toolCodeObs = (0, peers_sdk_1.computed)({
11
- read: () => {
12
- if (!code.startsWith("```")) {
13
- code = `\`\`\`javascript\n${code}\n\`\`\``;
14
- }
15
- return code;
16
- },
17
- write: (newCode) => {
18
- code = newCode;
19
- if (code.startsWith("```javascript\n")) {
20
- code = code.replace("```javascript\n", "");
21
- code = code.replace("\n```", "");
22
- }
23
- tool.qs.code(code);
24
- },
25
- });
26
- return ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(editor_inline_1.MarkdownEditorInline, { value: toolCodeObs, hideToolbar: true }) }));
27
- };
28
- exports.ToolCode = ToolCode;
@@ -1,30 +0,0 @@
1
- import { computed, type IDoc, type ITool, type Observable } from "@peers-app/peers-sdk";
2
- import { MarkdownEditorInline } from "../../components/markdown-editor/editor-inline";
3
-
4
- export const ToolCode = (props: { tool: IDoc<ITool> }) => {
5
- const { tool } = props;
6
-
7
- let code = tool.qs.code() ?? "";
8
- const toolCodeObs = computed({
9
- read: () => {
10
- if (!code.startsWith("```")) {
11
- code = `\`\`\`javascript\n${code}\n\`\`\``;
12
- }
13
- return code;
14
- },
15
- write: (newCode: string) => {
16
- code = newCode;
17
- if (code.startsWith("```javascript\n")) {
18
- code = code.replace("```javascript\n", "");
19
- code = code.replace("\n```", "");
20
- }
21
- tool.qs.code(code);
22
- },
23
- });
24
-
25
- return (
26
- <div>
27
- <MarkdownEditorInline value={toolCodeObs as Observable<string>} hideToolbar={true} />
28
- </div>
29
- );
30
- };