@memberjunction/ng-explorer-core 6.2.0-edge.2 → 6.2.0-edge.4

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 (139) hide show
  1. package/README.md +10 -0
  2. package/dist/app-routing.module.d.ts.map +1 -1
  3. package/dist/app-routing.module.js +5 -0
  4. package/dist/app-routing.module.js.map +1 -1
  5. package/dist/generated/lazy-feature-config.d.ts +1 -1
  6. package/dist/generated/lazy-feature-config.d.ts.map +1 -1
  7. package/dist/generated/lazy-feature-config.js +12 -2
  8. package/dist/generated/lazy-feature-config.js.map +1 -1
  9. package/dist/lib/guards/moved-nav-item-redirect.d.ts +15 -0
  10. package/dist/lib/guards/moved-nav-item-redirect.d.ts.map +1 -0
  11. package/dist/lib/guards/moved-nav-item-redirect.js +37 -0
  12. package/dist/lib/guards/moved-nav-item-redirect.js.map +1 -0
  13. package/dist/lib/omnibar/omnibar-dashboard-matches.d.ts +34 -0
  14. package/dist/lib/omnibar/omnibar-dashboard-matches.d.ts.map +1 -0
  15. package/dist/lib/omnibar/omnibar-dashboard-matches.js +70 -0
  16. package/dist/lib/omnibar/omnibar-dashboard-matches.js.map +1 -0
  17. package/dist/lib/omnibar/omnibar-palette.component.d.ts +27 -2
  18. package/dist/lib/omnibar/omnibar-palette.component.d.ts.map +1 -1
  19. package/dist/lib/omnibar/omnibar-palette.component.js +126 -65
  20. package/dist/lib/omnibar/omnibar-palette.component.js.map +1 -1
  21. package/dist/lib/omnibar/omnibar-provider.d.ts +4 -0
  22. package/dist/lib/omnibar/omnibar-provider.d.ts.map +1 -1
  23. package/dist/lib/omnibar/omnibar-provider.js.map +1 -1
  24. package/dist/lib/omnibar/providers/omnibar-record.provider.d.ts +2 -2
  25. package/dist/lib/omnibar/providers/omnibar-record.provider.d.ts.map +1 -1
  26. package/dist/lib/omnibar/providers/omnibar-record.provider.js +3 -7
  27. package/dist/lib/omnibar/providers/omnibar-record.provider.js.map +1 -1
  28. package/dist/lib/omnibar/providers/omnibar-search.provider.d.ts +15 -2
  29. package/dist/lib/omnibar/providers/omnibar-search.provider.d.ts.map +1 -1
  30. package/dist/lib/omnibar/providers/omnibar-search.provider.js +59 -7
  31. package/dist/lib/omnibar/providers/omnibar-search.provider.js.map +1 -1
  32. package/dist/lib/profile/forking-setting.d.ts +8 -0
  33. package/dist/lib/profile/forking-setting.d.ts.map +1 -0
  34. package/dist/lib/profile/forking-setting.js +18 -0
  35. package/dist/lib/profile/forking-setting.js.map +1 -0
  36. package/dist/lib/profile/profile-dialog.component.d.ts +8 -0
  37. package/dist/lib/profile/profile-dialog.component.d.ts.map +1 -1
  38. package/dist/lib/profile/profile-dialog.component.js +182 -92
  39. package/dist/lib/profile/profile-dialog.component.js.map +1 -1
  40. package/dist/lib/resource-wrappers/artifact-resource.component.d.ts +23 -4
  41. package/dist/lib/resource-wrappers/artifact-resource.component.d.ts.map +1 -1
  42. package/dist/lib/resource-wrappers/artifact-resource.component.js +36 -8
  43. package/dist/lib/resource-wrappers/artifact-resource.component.js.map +1 -1
  44. package/dist/lib/resource-wrappers/chat-conversations-resource.component.d.ts +7 -18
  45. package/dist/lib/resource-wrappers/chat-conversations-resource.component.d.ts.map +1 -1
  46. package/dist/lib/resource-wrappers/chat-conversations-resource.component.js +20 -41
  47. package/dist/lib/resource-wrappers/chat-conversations-resource.component.js.map +1 -1
  48. package/dist/lib/resource-wrappers/chat-search-routing.d.ts +10 -3
  49. package/dist/lib/resource-wrappers/chat-search-routing.d.ts.map +1 -1
  50. package/dist/lib/resource-wrappers/chat-search-routing.js +11 -4
  51. package/dist/lib/resource-wrappers/chat-search-routing.js.map +1 -1
  52. package/dist/lib/resource-wrappers/dashboard-add-to-menu.component.d.ts +29 -0
  53. package/dist/lib/resource-wrappers/dashboard-add-to-menu.component.d.ts.map +1 -0
  54. package/dist/lib/resource-wrappers/dashboard-add-to-menu.component.js +141 -0
  55. package/dist/lib/resource-wrappers/dashboard-add-to-menu.component.js.map +1 -0
  56. package/dist/lib/resource-wrappers/dashboard-part-config.d.ts +28 -0
  57. package/dist/lib/resource-wrappers/dashboard-part-config.d.ts.map +1 -0
  58. package/dist/lib/resource-wrappers/dashboard-part-config.js +150 -0
  59. package/dist/lib/resource-wrappers/dashboard-part-config.js.map +1 -0
  60. package/dist/lib/resource-wrappers/dashboard-resource.component.d.ts +361 -56
  61. package/dist/lib/resource-wrappers/dashboard-resource.component.d.ts.map +1 -1
  62. package/dist/lib/resource-wrappers/dashboard-resource.component.js +1312 -361
  63. package/dist/lib/resource-wrappers/dashboard-resource.component.js.map +1 -1
  64. package/dist/lib/resource-wrappers/dashboard-source-search.d.ts +86 -0
  65. package/dist/lib/resource-wrappers/dashboard-source-search.d.ts.map +1 -0
  66. package/dist/lib/resource-wrappers/dashboard-source-search.js +136 -0
  67. package/dist/lib/resource-wrappers/dashboard-source-search.js.map +1 -0
  68. package/dist/lib/resource-wrappers/dashboard-studio-pane.d.ts +30 -0
  69. package/dist/lib/resource-wrappers/dashboard-studio-pane.d.ts.map +1 -0
  70. package/dist/lib/resource-wrappers/dashboard-studio-pane.js +55 -0
  71. package/dist/lib/resource-wrappers/dashboard-studio-pane.js.map +1 -0
  72. package/dist/lib/resource-wrappers/dashboard-studio-tools.d.ts +135 -0
  73. package/dist/lib/resource-wrappers/dashboard-studio-tools.d.ts.map +1 -0
  74. package/dist/lib/resource-wrappers/dashboard-studio-tools.js +856 -0
  75. package/dist/lib/resource-wrappers/dashboard-studio-tools.js.map +1 -0
  76. package/dist/lib/resource-wrappers/dashboard-tab-agent.d.ts +87 -0
  77. package/dist/lib/resource-wrappers/dashboard-tab-agent.d.ts.map +1 -0
  78. package/dist/lib/resource-wrappers/dashboard-tab-agent.js +125 -0
  79. package/dist/lib/resource-wrappers/dashboard-tab-agent.js.map +1 -0
  80. package/dist/lib/resource-wrappers/human-handoff-console-resource.component.d.ts +32 -0
  81. package/dist/lib/resource-wrappers/human-handoff-console-resource.component.d.ts.map +1 -0
  82. package/dist/lib/resource-wrappers/human-handoff-console-resource.component.js +114 -0
  83. package/dist/lib/resource-wrappers/human-handoff-console-resource.component.js.map +1 -0
  84. package/dist/lib/resource-wrappers/livekit-room-resource.component.d.ts +40 -6
  85. package/dist/lib/resource-wrappers/livekit-room-resource.component.d.ts.map +1 -1
  86. package/dist/lib/resource-wrappers/livekit-room-resource.component.js +287 -147
  87. package/dist/lib/resource-wrappers/livekit-room-resource.component.js.map +1 -1
  88. package/dist/lib/resource-wrappers/meetings-resource.component.d.ts +42 -0
  89. package/dist/lib/resource-wrappers/meetings-resource.component.d.ts.map +1 -0
  90. package/dist/lib/resource-wrappers/meetings-resource.component.js +225 -0
  91. package/dist/lib/resource-wrappers/meetings-resource.component.js.map +1 -0
  92. package/dist/lib/resource-wrappers/record-resource.component.d.ts +27 -2
  93. package/dist/lib/resource-wrappers/record-resource.component.d.ts.map +1 -1
  94. package/dist/lib/resource-wrappers/record-resource.component.js +58 -6
  95. package/dist/lib/resource-wrappers/record-resource.component.js.map +1 -1
  96. package/dist/lib/resource-wrappers/search-results-resource.component.d.ts.map +1 -1
  97. package/dist/lib/resource-wrappers/search-results-resource.component.js +5 -4
  98. package/dist/lib/resource-wrappers/search-results-resource.component.js.map +1 -1
  99. package/dist/lib/shell/components/tabs/component-cache-manager.d.ts.map +1 -1
  100. package/dist/lib/shell/components/tabs/component-cache-manager.js.map +1 -1
  101. package/dist/lib/shell/components/tabs/tab-container.component.d.ts +38 -0
  102. package/dist/lib/shell/components/tabs/tab-container.component.d.ts.map +1 -1
  103. package/dist/lib/shell/components/tabs/tab-container.component.js +77 -4
  104. package/dist/lib/shell/components/tabs/tab-container.component.js.map +1 -1
  105. package/dist/lib/shell/dashboard-tab-for-url.d.ts +35 -0
  106. package/dist/lib/shell/dashboard-tab-for-url.d.ts.map +1 -0
  107. package/dist/lib/shell/dashboard-tab-for-url.js +39 -0
  108. package/dist/lib/shell/dashboard-tab-for-url.js.map +1 -0
  109. package/dist/lib/shell/shell.component.d.ts +31 -2
  110. package/dist/lib/shell/shell.component.d.ts.map +1 -1
  111. package/dist/lib/shell/shell.component.js +119 -42
  112. package/dist/lib/shell/shell.component.js.map +1 -1
  113. package/dist/lib/shell/shell.module.d.ts +2 -1
  114. package/dist/lib/shell/shell.module.d.ts.map +1 -1
  115. package/dist/lib/shell/shell.module.js +6 -2
  116. package/dist/lib/shell/shell.module.js.map +1 -1
  117. package/dist/lib/single-dashboard/single-dashboard.component.js +1 -1
  118. package/dist/lib/single-dashboard/single-dashboard.component.js.map +1 -1
  119. package/dist/lib/single-record/single-record.component.d.ts +29 -3
  120. package/dist/lib/single-record/single-record.component.d.ts.map +1 -1
  121. package/dist/lib/single-record/single-record.component.js +44 -4
  122. package/dist/lib/single-record/single-record.component.js.map +1 -1
  123. package/dist/lib/template-user/template-user-banner.component.d.ts +20 -0
  124. package/dist/lib/template-user/template-user-banner.component.d.ts.map +1 -0
  125. package/dist/lib/template-user/template-user-banner.component.js +67 -0
  126. package/dist/lib/template-user/template-user-banner.component.js.map +1 -0
  127. package/dist/lib/user-notifications/user-notifications.component.d.ts +13 -0
  128. package/dist/lib/user-notifications/user-notifications.component.d.ts.map +1 -1
  129. package/dist/lib/user-notifications/user-notifications.component.js +67 -0
  130. package/dist/lib/user-notifications/user-notifications.component.js.map +1 -1
  131. package/dist/module.d.ts +61 -56
  132. package/dist/module.d.ts.map +1 -1
  133. package/dist/module.js +32 -4
  134. package/dist/module.js.map +1 -1
  135. package/dist/public-api.d.ts +2 -0
  136. package/dist/public-api.d.ts.map +1 -1
  137. package/dist/public-api.js +2 -0
  138. package/dist/public-api.js.map +1 -1
  139. package/package.json +58 -52
@@ -0,0 +1,856 @@
1
+ import { UUIDsEqual } from '@memberjunction/global';
2
+ import { DescribePath, ExtractPanelsFromLayout, FindPanelPath, LayoutEditError, MovePanel, PANEL_PLACEMENTS, ResizePanel, SimplifyLayout, } from '@memberjunction/ng-dashboard-viewer';
3
+ import { DASHBOARD_TAB_AGENT_LIST_CAP } from './dashboard-tab-agent';
4
+ import { DASHBOARD_PART_TYPE_NAMES, DescribePartConfigs, MergePartConfig, ValidatePartConfig } from './dashboard-part-config';
5
+ import { SOURCE_KINDS } from './dashboard-source-search';
6
+ /** How long, in milliseconds, the edit queue waits for one edit before it reports that the edit did not finish and starts the next edit. */
7
+ export const DASHBOARD_STUDIO_EDIT_TIMEOUT_MS = 30_000;
8
+ const NO_DASHBOARD = 'No Config dashboard is open in this tab.';
9
+ const DASHBOARD_CHANGED = 'The open dashboard changed. Call GetDashboardState and use its dashboard.id.';
10
+ const DASHBOARD_ID_REQUIRED = 'dashboardId is required: call GetDashboardState and pass its dashboard.id.';
11
+ const SAVE_IN_PROGRESS = 'A save is in progress. Try again when it finishes.';
12
+ const READ_STATE_HINT = 'Call GetDashboardState to see the dashboard as it is now.';
13
+ const EDIT_TIMED_OUT = `The edit did not finish within ${DASHBOARD_STUDIO_EDIT_TIMEOUT_MS / 1000} seconds. It may still apply later. ${READ_STATE_HINT}`;
14
+ const DASHBOARD_ID_NOTE = 'Pass the dashboard.id from GetDashboardState as dashboardId; the call fails when another dashboard is open.';
15
+ const EDIT_NOTE = `It changes the open dashboard in memory only, entering edit mode first if needed; only the user can save. ${DASHBOARD_ID_NOTE}`;
16
+ const DASHBOARD_ID_SCHEMA = { type: 'string', description: 'The dashboard.id from GetDashboardState.' };
17
+ const PANEL_ID_SCHEMA = { type: 'string', description: 'The panel id from GetDashboardState.' };
18
+ /** The screenshot width ceiling, in pixels, when the agent gives none. */
19
+ const SCREENSHOT_MAX_WIDTH = 1280;
20
+ /** The results of each kind SearchSources gives when the agent sets no limit. */
21
+ const SEARCH_LIMIT_DEFAULT = 20;
22
+ /** The most results of each kind SearchSources gives. */
23
+ const SEARCH_LIMIT_MAX = 50;
24
+ /** What a panel shows, read from its config: the kind of source, and its id or name when the config has them. */
25
+ export function DescribePanelSource(config) {
26
+ const text = (key) => {
27
+ const value = config[key];
28
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
29
+ };
30
+ switch (config.type) {
31
+ case 'View': {
32
+ const viewId = text('viewId');
33
+ return viewId ? { kind: 'view', id: viewId, name: null } : { kind: 'entity', id: null, name: text('entityName') };
34
+ }
35
+ case 'Query':
36
+ return { kind: 'query', id: text('queryId'), name: text('queryName') };
37
+ case 'Artifact':
38
+ return { kind: 'artifact', id: text('artifactId'), name: null };
39
+ case 'WebURL':
40
+ return { kind: 'url', id: null, name: text('url') };
41
+ default:
42
+ return { kind: text('type')?.toLowerCase() ?? 'unknown', id: null, name: null };
43
+ }
44
+ }
45
+ /**
46
+ * The studio tools: GetDashboardState reads the open dashboard; AddPanel, RemovePanel, MovePanel,
47
+ * ResizePanel and UpdatePanelSettings change its panels and layout in memory; GetDashboardScreenshot
48
+ * captures it; RequestSaveDashboard and RequestPinToHome ask the user to save it or pin it to Home; and
49
+ * SearchSources finds the sources a panel can show. The tools of one call share a queue: it runs their
50
+ * edits one at a time, and the other tools start after the edits queued before them. So a tab registers
51
+ * one set of tools and keeps it.
52
+ */
53
+ export function BuildDashboardStudioTools(host) {
54
+ const edits = createEditQueue();
55
+ return [
56
+ getDashboardStateTool(host, edits),
57
+ addPanelTool(host, edits),
58
+ removePanelTool(host, edits),
59
+ movePanelTool(host, edits),
60
+ resizePanelTool(host, edits),
61
+ updatePanelSettingsTool(host, edits),
62
+ getDashboardScreenshotTool(host, edits),
63
+ requestSaveDashboardTool(host, edits),
64
+ requestPinToHomeTool(host, edits),
65
+ searchSourcesTool(host, edits),
66
+ ];
67
+ }
68
+ // ── tools ───────────────────────────────────────────────────────────────────
69
+ function getDashboardStateTool(host, edits) {
70
+ return {
71
+ Name: 'GetDashboardState',
72
+ Description: 'Reads the dashboard open in this tab: its name, description and edit state (isEditing, canEdit, hasUnsavedChanges); every panel ' +
73
+ 'with its id, title, icon, part type, source (view, entity, query, artifact or url), config, path and size; the layout tree; and ' +
74
+ 'the part types it can add. A path is the panel\'s address in the layout, for example row/0 › column/1 › tab 0. A size gives the ' +
75
+ 'panel\'s share in percent of the nearest row (widthPct) and column (heightPct) it shares with other panels, or null where it has ' +
76
+ 'none. Call this before changing anything, and after a change fails. The edit and Request tools take its dashboard.id as ' +
77
+ 'dashboardId. Read-only.',
78
+ ParameterSchema: { type: 'object', properties: {} },
79
+ Handler: async () => runAfterEdits(edits, () => readState(host)),
80
+ };
81
+ }
82
+ function addPanelTool(host, edits) {
83
+ return {
84
+ Name: 'AddPanel',
85
+ Description: `Adds a panel to the open dashboard. ${EDIT_NOTE} Pass the part type, its config without "type", and a title. Config keys by ` +
86
+ `part type: ${DescribePartConfigs()}. Use SearchSources to find ids. position is optional: { relativeTo: <panel id>, placement: ` +
87
+ 'left|right|above|below } puts the panel beside another panel, placement tab adds it to that panel\'s tab stack, and ' +
88
+ '{ placement: "end" } adds it at the right end of the layout; without a position the panel joins the first tab stack. ' +
89
+ 'Returns the new panel\'s id and path.',
90
+ ParameterSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ dashboardId: DASHBOARD_ID_SCHEMA,
94
+ partType: { type: 'string', enum: [...DASHBOARD_PART_TYPE_NAMES] },
95
+ config: { type: 'object', description: 'The part config without "type".' },
96
+ title: { type: 'string', description: 'The panel title.' },
97
+ icon: { type: 'string', description: 'Optional Font Awesome classes, for example "fa-solid fa-chart-line".' },
98
+ position: positionSchema('Optional. Where the panel goes.'),
99
+ },
100
+ required: ['dashboardId', 'partType', 'config', 'title'],
101
+ },
102
+ Handler: async (params) => runEdit(edits, host, params, () => checkAddPanel(host, params), async (add) => {
103
+ const panelId = await host.AddPanel(add.partTypeId, add.config, add.title, add.icon, add.position);
104
+ if (!panelId)
105
+ return fail(`The dashboard did not add the panel. ${READ_STATE_HINT}`);
106
+ return { Success: true, Data: { panelId, path: host.GetPanelPath(panelId) } };
107
+ }),
108
+ };
109
+ }
110
+ function removePanelTool(host, edits) {
111
+ return {
112
+ Name: 'RemovePanel',
113
+ Description: `Removes a panel from the open dashboard. ${EDIT_NOTE} Pass the panel id from GetDashboardState.`,
114
+ ParameterSchema: {
115
+ type: 'object',
116
+ properties: { dashboardId: DASHBOARD_ID_SCHEMA, panelId: PANEL_ID_SCHEMA },
117
+ required: ['dashboardId', 'panelId'],
118
+ },
119
+ Handler: async (params) => runEdit(edits, host, params, () => readPanel(params['panelId'], livePanels(host)), async (panel) => {
120
+ await host.RemovePanel(panel.id);
121
+ return { Success: true, Data: { removed: true, panelId: panel.id } };
122
+ }),
123
+ };
124
+ }
125
+ function movePanelTool(host, edits) {
126
+ return {
127
+ Name: 'MovePanel',
128
+ Description: 'Moves a panel beside another panel (left, right, above or below), into another panel\'s tab stack (tab), or to the right end ' +
129
+ `of the layout (end). ${EDIT_NOTE} The rows and columns the panel leaves and joins can share their space evenly afterwards; ` +
130
+ 'adjust sizes with ResizePanel. Returns the panel\'s new path.',
131
+ ParameterSchema: {
132
+ type: 'object',
133
+ properties: { dashboardId: DASHBOARD_ID_SCHEMA, panelId: PANEL_ID_SCHEMA, position: positionSchema('Where the panel goes.') },
134
+ required: ['dashboardId', 'panelId', 'position'],
135
+ },
136
+ Handler: async (params) => runEdit(edits, host, params, () => checkMovePanel(host, params), async (move) => {
137
+ await host.ApplyLayout(move.layout);
138
+ return { Success: true, Data: { moved: true, panelId: move.panelId, path: host.GetPanelPath(move.panelId) } };
139
+ }),
140
+ };
141
+ }
142
+ function resizePanelTool(host, edits) {
143
+ return {
144
+ Name: 'ResizePanel',
145
+ Description: 'Sets a panel\'s width as a share of its row (widthPct) or its height as a share of its column (heightPct), in percent; pass ' +
146
+ `either or both. ${EDIT_NOTE} The size applies to the nearest row or column the panel shares with other panels, and the other ` +
147
+ 'items there share the rest in proportion. A size stays between 5 and 95 and leaves every other item at least 5, so the panel ' +
148
+ 'can get a different size than asked; the result gives the size it got.',
149
+ ParameterSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ dashboardId: DASHBOARD_ID_SCHEMA,
153
+ panelId: PANEL_ID_SCHEMA,
154
+ widthPct: { type: 'number', description: 'The panel\'s share of its row, in percent (5 to 95).' },
155
+ heightPct: { type: 'number', description: 'The panel\'s share of its column, in percent (5 to 95).' },
156
+ },
157
+ required: ['dashboardId', 'panelId'],
158
+ },
159
+ Handler: async (params) => runEdit(edits, host, params, () => checkResizePanel(host, params), async (resize) => {
160
+ await host.ApplyLayout(resize.layout);
161
+ const size = readPanelSize(host.GetConfig()?.layout ?? null, resize.panelId);
162
+ return { Success: true, Data: { resized: true, panelId: resize.panelId, size } };
163
+ }),
164
+ };
165
+ }
166
+ function updatePanelSettingsTool(host, edits) {
167
+ return {
168
+ Name: 'UpdatePanelSettings',
169
+ Description: `Changes a panel's config, title or icon. ${EDIT_NOTE} config is a patch with only the keys to change: it is merged over the ` +
170
+ 'current config and checked again, and the part type cannot change. A key set to null is removed, and an empty config changes ' +
171
+ `only the title or icon. Config keys by part type: ${DescribePartConfigs()}.`,
172
+ ParameterSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ dashboardId: DASHBOARD_ID_SCHEMA,
176
+ panelId: PANEL_ID_SCHEMA,
177
+ config: { type: 'object', description: 'Only the config keys to change. null removes a key.' },
178
+ title: { type: 'string', description: 'A new title.' },
179
+ icon: { type: 'string', description: 'New Font Awesome classes.' },
180
+ },
181
+ required: ['dashboardId', 'panelId'],
182
+ },
183
+ Handler: async (params) => runEdit(edits, host, params, () => checkPanelSettings(host, params), async (change) => {
184
+ await host.UpdatePanelConfig(change.panelId, change.config, change.title, change.icon);
185
+ return { Success: true, Data: { updated: true, panelId: change.panelId } };
186
+ }),
187
+ };
188
+ }
189
+ function getDashboardScreenshotTool(host, edits) {
190
+ return {
191
+ Name: 'GetDashboardScreenshot',
192
+ Description: 'A JPEG of the dashboard as the user sees it, with each panel\'s bounds in image pixels. Use it only when a request depends on ' +
193
+ 'how things look (crowded, empty, colours, overlap). Iframe panels (WebURL) and some cross-origin images render blank in the ' +
194
+ 'capture, and only the shown tab of each tab stack is in it; that is a capture limit, not an empty panel. In a voice session ' +
195
+ 'the image also goes to the voice model as a video frame; sentToVoice says whether the voice session took it. Prefer ' +
196
+ 'GetDashboardState for structure. Read-only.',
197
+ ParameterSchema: {
198
+ type: 'object',
199
+ properties: { maxWidth: { type: 'number', description: `Image width ceiling in pixels. Default ${SCREENSHOT_MAX_WIDTH}.` } },
200
+ },
201
+ Handler: async (params) => runAfterEdits(edits, () => takeScreenshot(host, params)),
202
+ };
203
+ }
204
+ function requestSaveDashboardTool(host, edits) {
205
+ return {
206
+ Name: 'RequestSaveDashboard',
207
+ Description: 'Asks the user to save the open dashboard with a confirm dialog. The tool never saves by itself. An optional new name and ' +
208
+ 'description are shown in the dialog, and the save applies them. A blank name or description is ignored, so the current ' +
209
+ `one stays. ${DASHBOARD_ID_NOTE}`,
210
+ ParameterSchema: {
211
+ type: 'object',
212
+ properties: {
213
+ dashboardId: DASHBOARD_ID_SCHEMA,
214
+ name: { type: 'string', description: 'Optional. A new name to save the dashboard with. A blank name is ignored.' },
215
+ description: { type: 'string', description: 'Optional. A new description to save the dashboard with. A blank description is ignored.' },
216
+ },
217
+ required: ['dashboardId'],
218
+ },
219
+ Handler: async (params) => runAfterEdits(edits, () => requestSave(host, params)),
220
+ };
221
+ }
222
+ function requestPinToHomeTool(host, edits) {
223
+ return {
224
+ Name: 'RequestPinToHome',
225
+ Description: 'Asks the user to pin the open dashboard to their Home app as a card, with a confirm dialog. The tool never pins by itself. ' +
226
+ DASHBOARD_ID_NOTE,
227
+ ParameterSchema: { type: 'object', properties: { dashboardId: DASHBOARD_ID_SCHEMA }, required: ['dashboardId'] },
228
+ Handler: async (params) => runAfterEdits(edits, async () => {
229
+ const wrong = wrongDashboard(host, params['dashboardId']);
230
+ if (wrong)
231
+ return wrong;
232
+ return confirmResult(await host.Confirm('pin', {}));
233
+ }),
234
+ };
235
+ }
236
+ function searchSourcesTool(host, edits) {
237
+ return {
238
+ Name: 'SearchSources',
239
+ Description: 'Finds the artifacts, saved views, queries and entities the user can show in a dashboard panel, by a word or phrase in their ' +
240
+ 'name or description. Results come grouped by kind (artifact, view, query, entity), best match first: an exact name, then a ' +
241
+ 'name that starts with the query, then a name that contains it, then a description that contains it. Each result has a ' +
242
+ 'suggestedConfig for AddPanel: its type is the partType, and its other keys are the config. An entity result is a View ' +
243
+ 'panel of all the entity\'s records. fitsDashboard is false for an artifact type that does not suit a panel, such as code, ' +
244
+ 'files and images; add one only when the user asks for that artifact. Read-only.',
245
+ ParameterSchema: {
246
+ type: 'object',
247
+ properties: {
248
+ query: { type: 'string', description: 'A word or phrase to find in names and descriptions, ignoring case. "" lists the first sources by name.' },
249
+ kinds: { type: 'array', items: { type: 'string', enum: [...SOURCE_KINDS] }, description: 'The kinds to search. Default (missing or empty): every kind.' },
250
+ limit: { type: 'number', description: `The most results of each kind: ${SEARCH_LIMIT_DEFAULT} by default, ${SEARCH_LIMIT_MAX} at most.` },
251
+ },
252
+ required: ['query'],
253
+ },
254
+ Handler: async (params) => runAfterEdits(edits, () => searchSources(host, params)),
255
+ };
256
+ }
257
+ /** The JSON schema of a panel position. */
258
+ function positionSchema(description) {
259
+ return {
260
+ type: 'object',
261
+ description: `${description} { relativeTo: <panel id>, placement: left|right|above|below|tab } or { placement: "end" }.`,
262
+ properties: {
263
+ relativeTo: { type: 'string', description: 'The id of the panel to place this one beside. Not used with placement "end".' },
264
+ placement: { type: 'string', enum: [...PANEL_PLACEMENTS, 'end'] },
265
+ },
266
+ required: ['placement'],
267
+ };
268
+ }
269
+ function fail(message) {
270
+ return { Success: false, ErrorMessage: message };
271
+ }
272
+ function refuse(message) {
273
+ return { ok: false, result: fail(message) };
274
+ }
275
+ /** Runs a tool body and returns its result, or a failure with the message of anything it throws. */
276
+ async function run(body) {
277
+ try {
278
+ return await body();
279
+ }
280
+ catch (error) {
281
+ return fail(errorText(error));
282
+ }
283
+ }
284
+ /**
285
+ * A queue that starts each edit after the previous one has settled, whether it succeeded or failed, or
286
+ * has timed out, so every edit reads and changes the dashboard as the edit before it left it.
287
+ */
288
+ function createEditQueue() {
289
+ let tail = Promise.resolve();
290
+ return {
291
+ enqueue: edit => {
292
+ const result = tail.then(() => withEditTimeout(edit()));
293
+ tail = result.then(() => undefined, () => undefined);
294
+ return result;
295
+ },
296
+ whenIdle: () => tail,
297
+ };
298
+ }
299
+ /**
300
+ * Settles like the running edit, or with a failure when the edit has not settled within
301
+ * DASHBOARD_STUDIO_EDIT_TIMEOUT_MS. A rejected edit gives a failure with its message. An edit that timed
302
+ * out keeps running, and its result is dropped.
303
+ */
304
+ function withEditTimeout(running) {
305
+ return new Promise(resolve => {
306
+ const timer = setTimeout(() => resolve(fail(EDIT_TIMED_OUT)), DASHBOARD_STUDIO_EDIT_TIMEOUT_MS);
307
+ running.then(result => {
308
+ clearTimeout(timer);
309
+ resolve(result);
310
+ }, error => {
311
+ clearTimeout(timer);
312
+ resolve(fail(errorText(error)));
313
+ });
314
+ });
315
+ }
316
+ /**
317
+ * Runs a tool that does not edit once the edits queued before it have finished, so it sees what they
318
+ * changed. It does not hold the queue: an edit that arrives later can start while it runs.
319
+ */
320
+ function runAfterEdits(edits, body) {
321
+ return run(async () => {
322
+ await edits.whenIdle();
323
+ return body();
324
+ });
325
+ }
326
+ /**
327
+ * Runs an edit tool in the edit queue, so it starts after the previous edit has finished or timed out. It
328
+ * fails, before reading the rest of the call, when the call's `dashboardId` is not the open dashboard's
329
+ * (see wrongDashboard) or the tab cannot take an edit (see editRefusal). Then `check` reads the call and
330
+ * the dashboard and changes nothing, so a bad call leaves even the edit mode as it was. Then the tab
331
+ * enters edit mode if it is not editing, and `check` runs again, because entering edit mode can reload
332
+ * the saved dashboard. `apply` makes the edit by awaiting the host. A failure of the edit is returned
333
+ * with its message, and an edit that does not finish within DASHBOARD_STUDIO_EDIT_TIMEOUT_MS gives a
334
+ * failure that says so.
335
+ */
336
+ function runEdit(edits, host, params, check, apply) {
337
+ return edits.enqueue(() => run(async () => {
338
+ const refusal = wrongDashboard(host, params['dashboardId']) ?? editRefusal(host);
339
+ if (refusal)
340
+ return refusal;
341
+ const first = check();
342
+ if (!first.ok)
343
+ return first.result;
344
+ const wasEditing = host.IsEditing();
345
+ if (!wasEditing && !host.EnterEditMode())
346
+ return fail('The dashboard could not enter edit mode.');
347
+ const checked = wasEditing ? first : check();
348
+ if (!checked.ok)
349
+ return checked.result;
350
+ try {
351
+ return await apply(checked.value);
352
+ }
353
+ catch (error) {
354
+ return fail(`${asSentence(errorText(error))} ${READ_STATE_HINT}`);
355
+ }
356
+ }));
357
+ }
358
+ /**
359
+ * Why a call cannot act on the open dashboard: none is open, the call gives no dashboardId, or its
360
+ * dashboardId is another dashboard's, such as the one the tab showed when the agent read the state. Null
361
+ * when the call names the open dashboard.
362
+ */
363
+ function wrongDashboard(host, raw) {
364
+ const dashboard = host.Dashboard();
365
+ if (!dashboard)
366
+ return fail(NO_DASHBOARD);
367
+ if (typeof raw !== 'string' || !raw.trim())
368
+ return fail(DASHBOARD_ID_REQUIRED);
369
+ return UUIDsEqual(raw, dashboard.ID) ? null : fail(DASHBOARD_CHANGED);
370
+ }
371
+ /**
372
+ * Why the tab cannot take an edit now: no dashboard is open or it is still loading, a save runs, or the
373
+ * tab is not editing and the user cannot edit. Null when the edit can go ahead.
374
+ */
375
+ function editRefusal(host) {
376
+ const open = openDashboard(host);
377
+ if (!open.ok)
378
+ return open.result;
379
+ if (host.IsSaving())
380
+ return fail(SAVE_IN_PROGRESS);
381
+ return host.IsEditing() || host.CanEdit() ? null : fail('The user cannot edit this dashboard.');
382
+ }
383
+ /** The open Config dashboard and its configuration, or a refusal when none is open or it has not loaded. */
384
+ function openDashboard(host) {
385
+ const dashboard = host.Dashboard();
386
+ if (!dashboard)
387
+ return refuse(NO_DASHBOARD);
388
+ const config = host.GetConfig();
389
+ return config ? { ok: true, value: { dashboard, config } } : refuse('The dashboard is still loading. Try again in a moment.');
390
+ }
391
+ /** The message of a thrown value, or a stand-in when it has none. Never throws. */
392
+ function errorText(error) {
393
+ try {
394
+ const text = error instanceof Error ? error.message : String(error);
395
+ if (typeof text === 'string' && text.trim())
396
+ return text.trim();
397
+ }
398
+ catch {
399
+ // A thrown value that cannot be turned into text gets the stand-in below.
400
+ }
401
+ return 'The dashboard reported an error without a message.';
402
+ }
403
+ /** The text with a full stop at the end, unless it already ends a sentence. */
404
+ function asSentence(text) {
405
+ return /[.!?]$/.test(text) ? text : `${text}.`;
406
+ }
407
+ function readState(host) {
408
+ const open = openDashboard(host);
409
+ if (!open.ok)
410
+ return open.result;
411
+ const { dashboard, config } = open.value;
412
+ const partTypes = host.GetPartTypes();
413
+ const tree = SimplifyLayout(config.layout);
414
+ const panels = ExtractPanelsFromLayout(config.layout).map(panel => {
415
+ const path = FindPanelPath(config.layout, panel.id);
416
+ return {
417
+ id: panel.id,
418
+ title: panel.title,
419
+ icon: panel.icon ?? null,
420
+ partType: partTypes.find(pt => UUIDsEqual(pt.ID, panel.partTypeId))?.Name || panel.config.type || 'Unknown',
421
+ source: DescribePanelSource(panel.config),
422
+ config: panel.config,
423
+ path: path ? DescribePath(path) : null,
424
+ size: sizeOnPath(tree, path),
425
+ };
426
+ });
427
+ return {
428
+ Success: true,
429
+ Data: {
430
+ dashboard: {
431
+ id: dashboard.ID,
432
+ name: dashboard.Name,
433
+ description: dashboard.Description,
434
+ isEditing: host.IsEditing(),
435
+ canEdit: host.CanEdit(),
436
+ hasUnsavedChanges: host.HasUnsavedChanges(),
437
+ },
438
+ panels,
439
+ layout: tree,
440
+ partTypes: partTypes.map(pt => pt.Name),
441
+ },
442
+ };
443
+ }
444
+ /** The panels in the layout the tab shows now. */
445
+ function livePanels(host) {
446
+ return ExtractPanelsFromLayout(host.GetConfig()?.layout ?? null);
447
+ }
448
+ /** The panel's size in the layout. */
449
+ function readPanelSize(layout, panelId) {
450
+ return sizeOnPath(SimplifyLayout(layout), FindPanelPath(layout, panelId));
451
+ }
452
+ /**
453
+ * The size of the panel at the end of `path`, read from the simplified layout: the share of the item on
454
+ * the path in the nearest row, and in the nearest column, that holds more than one item. The layout
455
+ * editor resizes the same row and column. A share is null when there is no such row or column.
456
+ */
457
+ function sizeOnPath(tree, path) {
458
+ const size = { widthPct: null, heightPct: null };
459
+ let node = tree;
460
+ for (const step of path ?? []) {
461
+ const children = node?.children ?? [];
462
+ const child = children[step.index] ?? null;
463
+ if (node && (node.kind === 'row' || node.kind === 'column') && children.length > 1) {
464
+ size[node.kind === 'row' ? 'widthPct' : 'heightPct'] = child?.sizePct ?? null;
465
+ }
466
+ node = child;
467
+ }
468
+ return size;
469
+ }
470
+ /** Captures the dashboard, gives the panel bounds in image pixels, and sends the image to a running voice session. */
471
+ async function takeScreenshot(host, params) {
472
+ const open = openDashboard(host);
473
+ if (!open.ok)
474
+ return open.result;
475
+ const maxWidth = readMaxWidth(params['maxWidth']);
476
+ if (!maxWidth.ok)
477
+ return maxWidth.result;
478
+ const image = await captureScreenshot(host, maxWidth.value);
479
+ if (!image.ok)
480
+ return image.result;
481
+ const capture = image.value;
482
+ const panelBounds = boundsInImage(host.PanelBounds(), capture.Width);
483
+ return {
484
+ Success: true,
485
+ Data: { width: capture.Width, height: capture.Height, panelBounds, ...sendToVoice(host, capture) },
486
+ Media: [{ MimeType: capture.MimeType, Base64: capture.Base64, Width: capture.Width, Height: capture.Height }],
487
+ };
488
+ }
489
+ /** The screenshot, or a refusal that gives the reason the capture failed. */
490
+ async function captureScreenshot(host, maxWidth) {
491
+ try {
492
+ return { ok: true, value: await host.CaptureScreenshot(maxWidth) };
493
+ }
494
+ catch (error) {
495
+ return refuse(`Screenshot failed: ${asSentence(errorText(error))} Use GetDashboardState for the layout instead.`);
496
+ }
497
+ }
498
+ /**
499
+ * The panel bounds in image pixels. The host gives them in CSS pixels relative to the captured element,
500
+ * which the panels fill from side to side, so the scale is the image width over the right edge of the
501
+ * rightmost panel. With no panels, or a right edge under 1 CSS pixel (the panels have no size on screen),
502
+ * the bounds cannot be scaled and none are given.
503
+ */
504
+ function boundsInImage(bounds, imageWidth) {
505
+ const rightEdge = Math.max(...bounds.map(b => b.x + b.width));
506
+ if (!Number.isFinite(rightEdge) || rightEdge < 1)
507
+ return [];
508
+ const scale = imageWidth / rightEdge;
509
+ return bounds.map(b => ({
510
+ panelId: b.panelId,
511
+ x: Math.round(b.x * scale),
512
+ y: Math.round(b.y * scale),
513
+ width: Math.round(b.width * scale),
514
+ height: Math.round(b.height * scale),
515
+ }));
516
+ }
517
+ /**
518
+ * Sends the screenshot to the running voice session as a video frame. `sentToVoice` says whether the voice
519
+ * session took it; a send that throws is reported in `voiceFrameError` and does not fail the screenshot.
520
+ */
521
+ function sendToVoice(host, capture) {
522
+ if (!host.IsVoiceSessionActive())
523
+ return { sentToVoice: false };
524
+ try {
525
+ return { sentToVoice: host.SendVoiceFrame(capture.Base64, capture.MimeType) === true };
526
+ }
527
+ catch (error) {
528
+ return { sentToVoice: false, voiceFrameError: errorText(error) };
529
+ }
530
+ }
531
+ // ── requests to the user ────────────────────────────────────────────────────
532
+ /** Asks the user to save the open dashboard, with the new name and description the agent gives. */
533
+ async function requestSave(host, params) {
534
+ const wrong = wrongDashboard(host, params['dashboardId']);
535
+ if (wrong)
536
+ return wrong;
537
+ const open = openDashboard(host);
538
+ if (!open.ok)
539
+ return open.result;
540
+ if (host.IsSaving())
541
+ return fail(SAVE_IN_PROGRESS);
542
+ if (!host.IsEditing() && !host.HasUnsavedChanges())
543
+ return fail('There is nothing to save: the dashboard is not being edited.');
544
+ const name = readOptionalText(params['name'], 'name');
545
+ if (!name.ok)
546
+ return name.result;
547
+ const description = readOptionalText(params['description'], 'description');
548
+ if (!description.ok)
549
+ return description.result;
550
+ return confirmResult(await host.Confirm('save', { name: name.value, description: description.value }));
551
+ }
552
+ /** The result of a Request* tool for the user's answer. A decline is a failure the agent relays without retrying. */
553
+ function confirmResult(confirmed) {
554
+ return confirmed
555
+ ? { Success: true, Data: { requested: true, confirmed: true } }
556
+ : fail('The user declined. Do not retry unless they ask again.');
557
+ }
558
+ // ── searching sources ───────────────────────────────────────────────────────
559
+ /** Searches the sources a panel can show with the agent's query, kinds and limit. Data names the kinds it ignored. */
560
+ async function searchSources(host, params) {
561
+ const query = readSearchQuery(params['query']);
562
+ if (!query.ok)
563
+ return query.result;
564
+ const kinds = readSourceKinds(params['kinds']);
565
+ if (!kinds.ok)
566
+ return kinds.result;
567
+ const limit = readSearchLimit(params['limit']);
568
+ if (!limit.ok)
569
+ return limit.result;
570
+ const results = await host.SearchSources(query.value, kinds.value.kinds, limit.value);
571
+ const ignored = kinds.value.ignored;
572
+ return { Success: true, Data: { count: results.length, results, ...(ignored.length > 0 ? { ignoredKinds: ignored } : {}) } };
573
+ }
574
+ function checkAddPanel(host, params) {
575
+ const partTypeName = readPartTypeName(params['partType']);
576
+ if (!partTypeName.ok)
577
+ return partTypeName;
578
+ const name = partTypeName.value;
579
+ const partTypes = host.GetPartTypes();
580
+ const partType = partTypes.find(pt => namesMatch(pt.Name, name));
581
+ if (!partType)
582
+ return refuse(`The ${name} part type is not available in this dashboard. Part types it can add: ${listAddablePartTypes(partTypes)}.`);
583
+ const config = ValidatePartConfig(name, params['config']);
584
+ if (!config.ok)
585
+ return refuse(config.error);
586
+ const title = readText(params['title'], 'title');
587
+ if (!title.ok)
588
+ return title;
589
+ const icon = readOptionalText(params['icon'], 'icon');
590
+ if (!icon.ok)
591
+ return icon;
592
+ const position = readPosition(params['position'], livePanels(host), null);
593
+ if (!position.ok)
594
+ return position;
595
+ return { ok: true, value: { partTypeId: partType.ID, config: config.config, title: title.value, icon: icon.value, position: position.value } };
596
+ }
597
+ function checkMovePanel(host, params) {
598
+ const layout = host.GetConfig()?.layout ?? null;
599
+ const panels = ExtractPanelsFromLayout(layout);
600
+ const panel = readPanel(params['panelId'], panels);
601
+ if (!panel.ok)
602
+ return panel;
603
+ const panelId = panel.value.id;
604
+ const position = readPosition(params['position'], panels, panelId);
605
+ if (!position.ok)
606
+ return position;
607
+ const target = position.value;
608
+ if (!target)
609
+ return refuse('position is required: { relativeTo: <panel id>, placement: left|right|above|below|tab } or { placement: "end" }.');
610
+ return editLayout(panelId, () => MovePanel(layout, panelId, target));
611
+ }
612
+ function checkResizePanel(host, params) {
613
+ const layout = host.GetConfig()?.layout ?? null;
614
+ const panel = readPanel(params['panelId'], ExtractPanelsFromLayout(layout));
615
+ if (!panel.ok)
616
+ return panel;
617
+ const panelId = panel.value.id;
618
+ const widthPct = readPercent(params['widthPct'], 'widthPct');
619
+ if (!widthPct.ok)
620
+ return widthPct;
621
+ const heightPct = readPercent(params['heightPct'], 'heightPct');
622
+ if (!heightPct.ok)
623
+ return heightPct;
624
+ if (widthPct.value === undefined && heightPct.value === undefined)
625
+ return refuse('Pass widthPct, heightPct or both.');
626
+ const size = { widthPct: widthPct.value, heightPct: heightPct.value };
627
+ return editLayout(panelId, () => ResizePanel(layout, panelId, size));
628
+ }
629
+ function checkPanelSettings(host, params) {
630
+ const panel = readPanel(params['panelId'], livePanels(host));
631
+ if (!panel.ok)
632
+ return panel;
633
+ const title = readOptionalText(params['title'], 'title');
634
+ if (!title.ok)
635
+ return title;
636
+ const icon = readOptionalText(params['icon'], 'icon');
637
+ if (!icon.ok)
638
+ return icon;
639
+ const patch = readConfigPatch(params['config']);
640
+ if (!patch.ok)
641
+ return patch;
642
+ if (!patch.value && title.value === undefined && icon.value === undefined)
643
+ return refuse('Pass config, title or icon to change.');
644
+ const config = patch.value ? patchConfig(host, panel.value, patch.value) : { ok: true, value: panel.value.config };
645
+ if (!config.ok)
646
+ return config;
647
+ return { ok: true, value: { panelId: panel.value.id, config: config.value, title: title.value, icon: icon.value } };
648
+ }
649
+ /**
650
+ * The panel's config with the patch merged over it and checked against the panel's part type. A key the
651
+ * patch sets to null is removed first. When the merged config fails because of values the patch did not
652
+ * send, the refusal says that the panel's current config is invalid and names those keys.
653
+ */
654
+ function patchConfig(host, panel, patch) {
655
+ const partTypeName = configRulesName(host, panel);
656
+ if (!partTypeName) {
657
+ return refuse(`Panel "${panel.title}" is a ${panel.config.type || 'custom'} part; only its title and icon can be changed here.`);
658
+ }
659
+ const merged = withoutNullKeys(MergePartConfig(panel.config, patch), patch);
660
+ const checked = ValidatePartConfig(partTypeName, merged);
661
+ if (checked.ok)
662
+ return { ok: true, value: checked.config };
663
+ const stored = rejectedKeys(partTypeName, merged).filter(problem => !Object.hasOwn(patch, problem.key));
664
+ return refuse(stored.length ? invalidCurrentConfig(panel, stored) : checked.error);
665
+ }
666
+ /** A copy of the merged config without the keys the patch sets to null. `type` stays. */
667
+ function withoutNullKeys(merged, patch) {
668
+ const config = { ...merged };
669
+ for (const [key, value] of Object.entries(patch)) {
670
+ if (value === null && key !== 'type')
671
+ delete config[key];
672
+ }
673
+ return config;
674
+ }
675
+ /**
676
+ * The keys of `config` that the part type's rules reject: keys the rules do not allow, and values that fail
677
+ * their key's rule. Each key is checked beside one key that is valid on its own (a required key with a good
678
+ * value), so a missing required key is never counted. Empty when no key is valid on its own.
679
+ */
680
+ function rejectedKeys(partType, config) {
681
+ const entries = Object.entries(config).filter(([key]) => key !== 'type');
682
+ const anchor = entries.find(([key, value]) => ValidatePartConfig(partType, { [key]: value }).ok);
683
+ if (!anchor)
684
+ return [];
685
+ const [anchorKey, anchorValue] = anchor;
686
+ return entries.flatMap(([key, value]) => {
687
+ if (key === anchorKey)
688
+ return [];
689
+ const probe = ValidatePartConfig(partType, { [anchorKey]: anchorValue, [key]: value });
690
+ return probe.ok ? [] : [{ key, reason: probe.error }];
691
+ });
692
+ }
693
+ /** The refusal for a patch that fails because of values the panel holds and the call did not send. */
694
+ function invalidCurrentConfig(panel, problems) {
695
+ const reasons = problems.map(problem => problem.reason).join(' ');
696
+ const keys = problems.map(problem => problem.key).join(' and ');
697
+ return (`The current config of panel "${panel.title}" is invalid, in values this call did not send: ${reasons} ` +
698
+ `Send a valid value or null (which removes the key) for ${keys}, or change only the title or icon.`);
699
+ }
700
+ /** The part type whose config rules apply to the panel: its installed part type's name, else its config type. Null for other part types. */
701
+ function configRulesName(host, panel) {
702
+ const installed = host.GetPartTypes().find(pt => UUIDsEqual(pt.ID, panel.partTypeId));
703
+ const name = installed?.Name || panel.config.type;
704
+ return DASHBOARD_PART_TYPE_NAMES.find(known => namesMatch(name, known)) ?? null;
705
+ }
706
+ /** The layout an editor function returns, or a refusal for the LayoutEditError it throws. Other errors propagate. */
707
+ function editLayout(panelId, edit) {
708
+ try {
709
+ return { ok: true, value: { panelId, layout: edit() } };
710
+ }
711
+ catch (error) {
712
+ if (error instanceof LayoutEditError)
713
+ return refuse(error.message);
714
+ throw error;
715
+ }
716
+ }
717
+ // ── reading parameters ──────────────────────────────────────────────────────
718
+ function readPartTypeName(raw) {
719
+ const name = DASHBOARD_PART_TYPE_NAMES.find(known => namesMatch(raw, known));
720
+ return name ? { ok: true, value: name } : refuse(`partType must be one of ${DASHBOARD_PART_TYPE_NAMES.join(', ')}.`);
721
+ }
722
+ /** Required text, trimmed. */
723
+ function readText(raw, name) {
724
+ return typeof raw === 'string' && raw.trim() ? { ok: true, value: raw.trim() } : refuse(`${name} must be a non-empty string.`);
725
+ }
726
+ /** Optional text, trimmed; missing or blank text is undefined. */
727
+ function readOptionalText(raw, name) {
728
+ if (raw === undefined || raw === null)
729
+ return { ok: true, value: undefined };
730
+ if (typeof raw !== 'string')
731
+ return refuse(`${name} must be a string.`);
732
+ return { ok: true, value: raw.trim() || undefined };
733
+ }
734
+ /** An optional percentage. The layout editor keeps a size it applies within its bounds. */
735
+ function readPercent(raw, name) {
736
+ if (raw === undefined || raw === null)
737
+ return { ok: true, value: undefined };
738
+ return typeof raw === 'number' && Number.isFinite(raw) ? { ok: true, value: raw } : refuse(`${name} must be a number, such as 40 for 40%.`);
739
+ }
740
+ /** The screenshot width ceiling: a number of pixels, rounded, or the default when none is given. */
741
+ function readMaxWidth(raw) {
742
+ if (raw === undefined || raw === null)
743
+ return { ok: true, value: SCREENSHOT_MAX_WIDTH };
744
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 1
745
+ ? { ok: true, value: Math.round(raw) }
746
+ : refuse(`maxWidth must be a number of pixels, such as ${SCREENSHOT_MAX_WIDTH}.`);
747
+ }
748
+ /** The search query, trimmed. A missing query is the empty query, which lists the first sources by name. */
749
+ function readSearchQuery(raw) {
750
+ if (raw === undefined || raw === null)
751
+ return { ok: true, value: '' };
752
+ return typeof raw === 'string'
753
+ ? { ok: true, value: raw.trim() }
754
+ : refuse('query must be text: a word or phrase from a name or description, or "" to list the first sources by name.');
755
+ }
756
+ /**
757
+ * The source kinds to search, in SOURCE_KINDS order, matched ignoring case and outer spaces, and the entries
758
+ * that name no kind. A missing or empty list means every kind.
759
+ */
760
+ function readSourceKinds(raw) {
761
+ if (raw === undefined || raw === null)
762
+ return { ok: true, value: { kinds: SOURCE_KINDS, ignored: [] } };
763
+ if (!Array.isArray(raw))
764
+ return refuse(`kinds must be a list of source kinds: ${SOURCE_KINDS.join(', ')}.`);
765
+ const entries = raw;
766
+ if (entries.length === 0)
767
+ return { ok: true, value: { kinds: SOURCE_KINDS, ignored: [] } };
768
+ const kinds = SOURCE_KINDS.filter(kind => entries.some(entry => namesMatch(entry, kind)));
769
+ if (kinds.length === 0)
770
+ return refuse(`kinds must contain one of ${SOURCE_KINDS.join(', ')}.`);
771
+ return { ok: true, value: { kinds, ignored: entries.filter(entry => !SOURCE_KINDS.some(kind => namesMatch(entry, kind))) } };
772
+ }
773
+ /** The most search results of each kind: a number of at least 1, rounded and capped at SEARCH_LIMIT_MAX, or the default when none is given. */
774
+ function readSearchLimit(raw) {
775
+ if (raw === undefined || raw === null)
776
+ return { ok: true, value: SEARCH_LIMIT_DEFAULT };
777
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 1
778
+ ? { ok: true, value: Math.min(SEARCH_LIMIT_MAX, Math.round(raw)) }
779
+ : refuse(`limit must be a number of results per kind, such as ${SEARCH_LIMIT_DEFAULT} (at most ${SEARCH_LIMIT_MAX}).`);
780
+ }
781
+ /** The config patch, or undefined when there is none: a missing, null or empty config changes no config key. */
782
+ function readConfigPatch(raw) {
783
+ if (raw === undefined || raw === null)
784
+ return { ok: true, value: undefined };
785
+ if (!isRecord(raw))
786
+ return refuse('config must be an object with the config keys to change.');
787
+ return { ok: true, value: Object.keys(raw).length > 0 ? raw : undefined };
788
+ }
789
+ /**
790
+ * The panel with this id, else the one panel with this title (ignoring case). A failure names the
791
+ * parameter it read (`name`) and lists the panels.
792
+ */
793
+ function readPanel(raw, panels, name = 'panelId') {
794
+ const key = typeof raw === 'string' ? raw.trim() : '';
795
+ if (!key)
796
+ return refuse(`${name} is required: pass the id of a panel from GetDashboardState. Panels: ${listPanels(panels)}.`);
797
+ const byId = panels.find(p => p.id === key);
798
+ if (byId)
799
+ return { ok: true, value: byId };
800
+ const byTitle = panels.filter(p => namesMatch(p.title, key));
801
+ if (byTitle.length === 1)
802
+ return { ok: true, value: byTitle[0] };
803
+ if (byTitle.length > 1)
804
+ return refuse(`${name} "${key}" matches more than one panel title: ${listPanels(byTitle)}. Pass a panel id.`);
805
+ return refuse(`${name} "${key}" matches no panel id or title. Panels: ${listPanels(panels)}.`);
806
+ }
807
+ /**
808
+ * An optional position: `{ placement: 'end' }`, or a placement beside the `relativeTo` panel. A panel
809
+ * being moved (`movingId`) cannot be placed relative to itself.
810
+ */
811
+ function readPosition(raw, panels, movingId) {
812
+ if (raw === undefined || raw === null)
813
+ return { ok: true, value: undefined };
814
+ if (!isRecord(raw)) {
815
+ return refuse('position must be an object: { relativeTo: <panel id>, placement: left|right|above|below|tab } or { placement: "end" }.');
816
+ }
817
+ const requested = raw['placement'];
818
+ const placementName = typeof requested === 'string' ? requested.trim().toLowerCase() : '';
819
+ if (placementName === 'end')
820
+ return { ok: true, value: { placement: 'end' } };
821
+ const placement = PANEL_PLACEMENTS.find(known => known === placementName);
822
+ if (!placement)
823
+ return refuse(`position.placement must be one of ${[...PANEL_PLACEMENTS, 'end'].join(', ')}.`);
824
+ const relativeTo = raw['relativeTo'];
825
+ if (typeof relativeTo !== 'string' || !relativeTo.trim()) {
826
+ return refuse(`position.relativeTo is required for placement "${placement}": pass the id of a panel from GetDashboardState.`);
827
+ }
828
+ const target = readPanel(relativeTo, panels, 'position.relativeTo');
829
+ if (!target.ok)
830
+ return target;
831
+ if (target.value.id === movingId)
832
+ return refuse('A panel cannot be placed relative to itself.');
833
+ return { ok: true, value: { relativeTo: target.value.id, placement } };
834
+ }
835
+ /** "Title (id)" for each panel, at most DASHBOARD_TAB_AGENT_LIST_CAP of them, or "(none)". */
836
+ function listPanels(panels) {
837
+ if (panels.length === 0)
838
+ return '(none)';
839
+ const listed = panels.slice(0, DASHBOARD_TAB_AGENT_LIST_CAP).map(p => `${p.title || '(untitled)'} (${p.id})`).join(', ');
840
+ const more = panels.length - DASHBOARD_TAB_AGENT_LIST_CAP;
841
+ return more > 0 ? `${listed} and ${more} more` : listed;
842
+ }
843
+ /** The part types AddPanel can add that the dashboard has, by name, or "(none)". */
844
+ function listAddablePartTypes(partTypes) {
845
+ const names = DASHBOARD_PART_TYPE_NAMES.filter(name => partTypes.some(pt => namesMatch(pt.Name, name)));
846
+ return names.length > 0 ? names.join(', ') : '(none)';
847
+ }
848
+ /** True when `raw` is text that equals `name`, ignoring case and outer spaces. */
849
+ function namesMatch(raw, name) {
850
+ return typeof raw === 'string' && raw.trim().toLowerCase() === name.toLowerCase();
851
+ }
852
+ /** True for an object that is not null and not an array. */
853
+ function isRecord(value) {
854
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
855
+ }
856
+ //# sourceMappingURL=dashboard-studio-tools.js.map