@k2b/cloud 0.38.0 → 0.40.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.
- package/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
|
@@ -44,6 +44,9 @@ export type CloudResourceSearchProps = {
|
|
|
44
44
|
excludeRefs?: readonly CloudResourceRef[];
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
/** What `/api/search` accepts as `q`; a prefilled query is cut to it as typing is. */
|
|
48
|
+
const MAX_QUERY_LENGTH = 500;
|
|
49
|
+
|
|
47
50
|
const isLinkableCommand = (command: PaletteCommand) => typeof command.action !== "function" && "command" in command.action;
|
|
48
51
|
|
|
49
52
|
const itemKey = (item: SearchItem) => `${item.ref.type}:${item.ref.id}`;
|
|
@@ -62,23 +65,24 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
62
65
|
const locale = useLocale();
|
|
63
66
|
const t = () => resourceSearchMessages.resolve([locale()]).t;
|
|
64
67
|
const id = createUniqueId();
|
|
65
|
-
const [input, setInput] = createSignal("");
|
|
68
|
+
const [input, setInput] = createSignal((props.request?.query ?? "").slice(0, MAX_QUERY_LENGTH));
|
|
66
69
|
const [tags, setTags] = createSignal<string[]>([]);
|
|
67
70
|
const [scope, setScope] = createSignal<SearchScope | undefined>(
|
|
68
71
|
props.request?.scope ?? (props.initialAppId ? { appId: props.initialAppId, label: props.initialAppId } : undefined),
|
|
69
72
|
);
|
|
70
73
|
const appId = () => scope()?.appId ?? scope()?.ref?.type.split(".")[0];
|
|
71
74
|
const scopeLabel = () =>
|
|
72
|
-
!props.request && props.initialAppId
|
|
75
|
+
!props.request?.scope && props.initialAppId
|
|
73
76
|
? (catalogApps()?.find((app) => app.id === props.initialAppId)?.name ?? scope()?.label)
|
|
74
77
|
: scope()?.label;
|
|
75
78
|
createEffect(() => {
|
|
76
79
|
const request = props.request;
|
|
77
80
|
if (!request) return;
|
|
78
|
-
setScope(request.scope);
|
|
81
|
+
setScope(request.scope ?? (props.initialAppId ? { appId: props.initialAppId, label: props.initialAppId } : undefined));
|
|
79
82
|
if (request.query !== undefined) {
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
const query = request.query.slice(0, MAX_QUERY_LENGTH);
|
|
84
|
+
setInput(query);
|
|
85
|
+
setCaret(query.length);
|
|
82
86
|
}
|
|
83
87
|
setTags([]);
|
|
84
88
|
setBrowsingTags(false);
|
|
@@ -556,7 +560,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
556
560
|
autocomplete="off"
|
|
557
561
|
autocapitalize="off"
|
|
558
562
|
spellcheck={false}
|
|
559
|
-
maxLength={
|
|
563
|
+
maxLength={MAX_QUERY_LENGTH}
|
|
560
564
|
/>
|
|
561
565
|
</div>
|
|
562
566
|
</label>
|
|
@@ -7,10 +7,19 @@ import {
|
|
|
7
7
|
APP_ACTION_LIMITS,
|
|
8
8
|
APP_APPROVAL_LIMITS,
|
|
9
9
|
APP_APPROVAL_PATH,
|
|
10
|
+
APP_APPROVAL_PENDING_ACCEPT,
|
|
10
11
|
APP_APPROVAL_PROTOCOL,
|
|
11
12
|
type AppActionView,
|
|
12
13
|
AppActionViewSchema,
|
|
14
|
+
type AppApprovalAccept,
|
|
15
|
+
type AppApprovalPendingAccept,
|
|
16
|
+
type AppDeviceChange,
|
|
17
|
+
AppDeviceChangeSchema,
|
|
13
18
|
AppDeviceCommandSchema,
|
|
19
|
+
type AppDeviceInbox,
|
|
20
|
+
AppDeviceInboxSchema,
|
|
21
|
+
type AppDeviceInboxWithChanges,
|
|
22
|
+
AppDeviceInboxWithChangesSchema,
|
|
14
23
|
AppDeviceProofSchema,
|
|
15
24
|
type AppDevicePublicKey,
|
|
16
25
|
AppDevicePublicKeySchema,
|
|
@@ -196,7 +205,8 @@ const connect = async (options: {
|
|
|
196
205
|
);
|
|
197
206
|
},
|
|
198
207
|
pairingResult: (payload: AppPairingPayload, key: AppApprovalKey, signal?: AbortSignal) => {
|
|
199
|
-
const p =
|
|
208
|
+
const p = AppPairingPayloadSchema.parse(payload);
|
|
209
|
+
if (p.issuer !== issuer) throw new AppApprovalClientError("INVALID_INPUT");
|
|
200
210
|
return request(
|
|
201
211
|
"/pairings/result",
|
|
202
212
|
AppPairingResultSchema,
|
|
@@ -204,8 +214,10 @@ const connect = async (options: {
|
|
|
204
214
|
signal,
|
|
205
215
|
);
|
|
206
216
|
},
|
|
207
|
-
pending: async (device: AppApprovalDevice, signal?: AbortSignal) => {
|
|
208
|
-
|
|
217
|
+
pending: async (device: AppApprovalDevice, signal?: AbortSignal, options?: { accept?: AppApprovalPendingAccept[] }) => {
|
|
218
|
+
// Untyped callers may reuse their inbox list; drop what pending does not serve instead of failing every poll.
|
|
219
|
+
const accept = options?.accept?.filter((entry) => APP_APPROVAL_PENDING_ACCEPT.includes(entry) && info.features?.includes(entry));
|
|
220
|
+
const result = await command(device, { operation: "pending", ...(accept?.length ? { accept } : {}) }, signal);
|
|
209
221
|
if (!("requests" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
210
222
|
return result;
|
|
211
223
|
},
|
|
@@ -223,10 +235,48 @@ const connect = async (options: {
|
|
|
223
235
|
},
|
|
224
236
|
/** Whether this Cloud serves `inbox`, `action` and `act`. Older Clouds answer those commands with HTTP 400. */
|
|
225
237
|
actions: info.features?.includes("actions") === true,
|
|
238
|
+
/** Whether this Cloud can include terminal consent in an explicitly opted-in inbox. */
|
|
239
|
+
terminalLogins: info.features?.includes("terminal-logins") === true,
|
|
240
|
+
/** Whether this Cloud serves sign-in context through a signed opt-in. */
|
|
241
|
+
loginContext: info.features?.includes("login-context") === true,
|
|
242
|
+
/** Whether this Cloud lists requests to pair or remove a device in an inbox that accepts `device-changes`. */
|
|
243
|
+
deviceChanges: info.features?.includes("device-changes") === true,
|
|
226
244
|
/** Pending sign-ins and the newest open Action approvals in one call; use it instead of `pending` where `actions` is true. */
|
|
227
|
-
inbox: async (
|
|
228
|
-
|
|
229
|
-
|
|
245
|
+
inbox: async (
|
|
246
|
+
device: AppApprovalDevice,
|
|
247
|
+
signal?: AbortSignal,
|
|
248
|
+
options?: { accept?: AppApprovalAccept[] },
|
|
249
|
+
): Promise<AppDeviceInbox | AppDeviceInboxWithChanges> => {
|
|
250
|
+
// SDK upgrades alone must not expose consent the app cannot show. Old Clouds never see the new key.
|
|
251
|
+
const accept = options?.accept?.filter((entry) => info.features?.includes(entry));
|
|
252
|
+
const result = await command(device, { operation: "inbox", ...(accept?.length ? { accept } : {}) }, signal);
|
|
253
|
+
if (!("actions" in result) || (accept?.includes("device-changes") && (!("changes" in result) || !("moreChanges" in result))))
|
|
254
|
+
throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
255
|
+
return "changes" in result ? AppDeviceInboxWithChangesSchema.parse(result) : AppDeviceInboxSchema.parse(result);
|
|
256
|
+
},
|
|
257
|
+
/**
|
|
258
|
+
* Signs a decision on one entry of `changes`, a request to pair or remove a device of this account. Only call it on an
|
|
259
|
+
* explicit choice of the person. The device being removed can approve its own removal; its `deny` throws
|
|
260
|
+
* `AppApprovalClientError` with code `HTTP` and status 409.
|
|
261
|
+
*/
|
|
262
|
+
decideChange: async (device: AppApprovalDevice, change: AppDeviceChange, decision: "approve" | "deny", signal?: AbortSignal) => {
|
|
263
|
+
const parsed = AppDeviceChangeSchema.safeParse(change);
|
|
264
|
+
if (!parsed.success || Date.parse(parsed.data.expiresAt) <= Date.now()) throw new AppApprovalClientError("INVALID_INPUT");
|
|
265
|
+
change = parsed.data;
|
|
266
|
+
const result = await command(
|
|
267
|
+
device,
|
|
268
|
+
{
|
|
269
|
+
operation: "change",
|
|
270
|
+
changeId: change.changeId,
|
|
271
|
+
kind: change.kind,
|
|
272
|
+
targetId: change.device.id,
|
|
273
|
+
endSessions: change.endSessions,
|
|
274
|
+
decision,
|
|
275
|
+
},
|
|
276
|
+
signal,
|
|
277
|
+
);
|
|
278
|
+
if (!("state" in result) || result.state !== (decision === "approve" ? "approved" : "denied"))
|
|
279
|
+
throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
230
280
|
return result;
|
|
231
281
|
},
|
|
232
282
|
/** One open Action approval. The digest is recomputed here, so a view that does not match it is never returned. */
|
|
@@ -258,6 +308,10 @@ const connect = async (options: {
|
|
|
258
308
|
if (!("state" in result) || result.state !== "updated") throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
259
309
|
return result;
|
|
260
310
|
},
|
|
311
|
+
/**
|
|
312
|
+
* Removes this device itself at once, without approval from a paired device. Sign-ins it approved stay signed in.
|
|
313
|
+
* Removing a device from Cloud in the browser waits for a paired device to approve instead.
|
|
314
|
+
*/
|
|
261
315
|
revoke: async (device: AppApprovalDevice, signal?: AbortSignal) => {
|
|
262
316
|
const result = await command(device, { operation: "revoke" }, signal);
|
|
263
317
|
if (!("state" in result) || result.state !== "revoked") throw new AppApprovalClientError("INVALID_RESPONSE");
|
package/src/browser/commands.ts
CHANGED
|
@@ -14,17 +14,33 @@ export type { CommandOptions } from "../contracts/commands";
|
|
|
14
14
|
export type { CommandTarget, ContextAwareCommand } from "./command-bridge";
|
|
15
15
|
export { registerCommandHandler, registerContextAwareCommand } from "./command-bridge";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Opens locally only when a mounted owner accepts; otherwise follows the app's URL.
|
|
19
|
+
* Resolves true after the owner's handler resolves or navigation starts.
|
|
20
|
+
* Resolves false if code cannot load (with a reload notice) or `signal` aborts before handling or while resolving the link.
|
|
21
|
+
* An accepting owner takes over without the signal. Handler rejections and link resolution failures still reject.
|
|
22
|
+
*/
|
|
23
|
+
export const openCommand = async (
|
|
24
|
+
command: string,
|
|
25
|
+
input: unknown = {},
|
|
26
|
+
options: CommandOptions = {},
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
): Promise<boolean> => {
|
|
19
29
|
const modules = await importOnDemand(() => Promise.all([import("../contracts/commands"), import("../capabilities/client")]));
|
|
20
|
-
if (!modules) return;
|
|
30
|
+
if (!modules || signal?.aborted) return false;
|
|
21
31
|
const [contracts, capabilities] = modules;
|
|
22
32
|
const parsedOptions = contracts.CommandOptionsSchema.parse(options);
|
|
23
33
|
const handled = requestCommandHandling({ command, input, options: parsedOptions });
|
|
24
|
-
if (handled)
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
if (handled) {
|
|
35
|
+
await handled;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
// An abort also ends the request, so a cancelled opening does not wait for its answer.
|
|
39
|
+
const result = await capabilities.resolveCommand(command, input, parsedOptions, { signal });
|
|
40
|
+
if (signal?.aborted) return false;
|
|
41
|
+
if (!result.ok) throw new Error(result.error.message, { cause: result.error });
|
|
27
42
|
window.location.assign(result.data.href);
|
|
43
|
+
return true;
|
|
28
44
|
};
|
|
29
45
|
|
|
30
46
|
/** Call after this page's handlers have mounted. Each entry is consumed, including invalid links. */
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ChatCommandSearch } from "@k2b/ui";
|
|
2
|
+
import { createSignal } from "solid-js";
|
|
3
|
+
import { listCapabilityCatalog } from "../capabilities/client";
|
|
4
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
5
|
+
import { discoverableCommandInput } from "./discoverable-command";
|
|
6
|
+
import { navigationApps } from "./navigation-apps";
|
|
7
|
+
|
|
8
|
+
export type CloudSearchTag = {
|
|
9
|
+
appId: string;
|
|
10
|
+
appName: string;
|
|
11
|
+
appIcon: string;
|
|
12
|
+
tag: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
aliases: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CloudComposerCommand = {
|
|
19
|
+
id: string;
|
|
20
|
+
appId: string;
|
|
21
|
+
appName: string;
|
|
22
|
+
appIcon: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description: string;
|
|
25
|
+
icon: string;
|
|
26
|
+
keywords: readonly string[];
|
|
27
|
+
input: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What composers read from the page's one Capability catalog load. It leaves out the tags of apps without a resource
|
|
32
|
+
* reader: a picker that requires one can show none of their results. A composer that reads such a type itself adds its
|
|
33
|
+
* tag on its own and names the type in `cloudInsertMenu`'s `ownReaders`.
|
|
34
|
+
*/
|
|
35
|
+
export type CloudComposerCatalog = { tags: readonly CloudSearchTag[]; commands: readonly CloudComposerCommand[] };
|
|
36
|
+
export type CloudComposerCatalogState = "loading" | "ready" | "failed";
|
|
37
|
+
|
|
38
|
+
/** The budget of one Capability query (and of resolving a Command), applied to each catalog page. */
|
|
39
|
+
const CATALOG_PAGE_TIMEOUT_MS = 15_000;
|
|
40
|
+
|
|
41
|
+
const catalogs = new Map<string, Promise<CloudComposerCatalog>>();
|
|
42
|
+
|
|
43
|
+
const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
|
|
44
|
+
const tags: CloudSearchTag[] = [];
|
|
45
|
+
const commands: CloudComposerCommand[] = [];
|
|
46
|
+
const { appIds, currentAppId } = navigationApps();
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
let cursor: string | undefined;
|
|
49
|
+
do {
|
|
50
|
+
// A page that never answers fails like any other, so the load is forgotten and the next opening asks again.
|
|
51
|
+
const result = await listCapabilityCatalog({
|
|
52
|
+
limit: 25,
|
|
53
|
+
cursor,
|
|
54
|
+
headers: { [LOCALE_HEADER]: locale },
|
|
55
|
+
signal: AbortSignal.timeout(CATALOG_PAGE_TIMEOUT_MS),
|
|
56
|
+
});
|
|
57
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
58
|
+
for (const app of result.data.apps) {
|
|
59
|
+
if (app.appId !== currentAppId) {
|
|
60
|
+
for (const command of app.manifest.commands) {
|
|
61
|
+
const input = discoverableCommandInput(command, app.appId, appIds);
|
|
62
|
+
if (!input?.success) continue;
|
|
63
|
+
commands.push({
|
|
64
|
+
id: `${app.appId}.${command.localId}`,
|
|
65
|
+
appId: app.appId,
|
|
66
|
+
appName: app.appName,
|
|
67
|
+
appIcon: app.appIcon,
|
|
68
|
+
title: command.title,
|
|
69
|
+
description: command.description,
|
|
70
|
+
icon: command.icon ?? app.appIcon,
|
|
71
|
+
keywords: command.keywords,
|
|
72
|
+
input: input.data,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!app.manifest.types.some((type) => type.reader)) continue;
|
|
77
|
+
for (const query of app.manifest.queries) {
|
|
78
|
+
for (const tag of query.universalSearch?.tags ?? []) {
|
|
79
|
+
const key = `${app.appId}\u0000${tag.tag}`;
|
|
80
|
+
if (seen.has(key)) continue;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
tags.push({ appId: app.appId, appName: app.appName, appIcon: app.appIcon, ...tag, aliases: tag.aliases ?? [] });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const next = result.data.page.hasMore ? result.data.page.nextCursor : undefined;
|
|
87
|
+
if (next && cursor && next <= cursor) throw new Error("Invalid Composer catalog cursor");
|
|
88
|
+
cursor = next;
|
|
89
|
+
} while (cursor);
|
|
90
|
+
return { tags, commands };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One catalog load per page and locale, shared by every composer on the page. A failed load is forgotten,
|
|
95
|
+
* so the next menu opening can try again; nothing polls or retries on its own.
|
|
96
|
+
*/
|
|
97
|
+
export const loadCloudComposerCatalog = (locale: string): Promise<CloudComposerCatalog> => {
|
|
98
|
+
const cached = catalogs.get(locale);
|
|
99
|
+
if (cached) return cached;
|
|
100
|
+
const pending = loadCatalog(locale).catch((error: unknown) => {
|
|
101
|
+
if (catalogs.get(locale) === pending) catalogs.delete(locale);
|
|
102
|
+
throw error;
|
|
103
|
+
});
|
|
104
|
+
catalogs.set(locale, pending);
|
|
105
|
+
return pending;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The search tags, Commands, and load state of one composer. Call `ready` at the start of every `searchCommands` run: each menu opening starts
|
|
110
|
+
* the shared load once, so only the first opening, or one after a failure, sends a request, and no keystroke does.
|
|
111
|
+
* A search with typed text and no scope waits for that answer, so "/notiz" never settles as "nothing found" before its
|
|
112
|
+
* tag arrives. The overview and scoped searches do not wait, and after a failed load no search waits again; a later
|
|
113
|
+
* success still brings the tags in. A failure keeps the tags the composer had.
|
|
114
|
+
*/
|
|
115
|
+
export function createCloudComposerCatalog(load: (locale: string) => Promise<CloudComposerCatalog> = loadCloudComposerCatalog) {
|
|
116
|
+
const [tags, setTags] = createSignal<readonly CloudSearchTag[]>([]);
|
|
117
|
+
const [commands, setCommands] = createSignal<readonly CloudComposerCommand[]>([]);
|
|
118
|
+
const [state, setState] = createSignal<CloudComposerCatalogState>("loading");
|
|
119
|
+
let opening: number | undefined;
|
|
120
|
+
let settled: Promise<void> = Promise.resolve();
|
|
121
|
+
let failed = false;
|
|
122
|
+
return {
|
|
123
|
+
tags,
|
|
124
|
+
commands,
|
|
125
|
+
state,
|
|
126
|
+
ready(query: string, search: ChatCommandSearch, locale: string): Promise<void> {
|
|
127
|
+
if (opening !== search.opening) {
|
|
128
|
+
opening = search.opening;
|
|
129
|
+
if (state() === "failed") setState("loading");
|
|
130
|
+
settled = load(locale).then(
|
|
131
|
+
(catalog) => {
|
|
132
|
+
setTags(catalog.tags);
|
|
133
|
+
setCommands(catalog.commands);
|
|
134
|
+
setState("ready");
|
|
135
|
+
},
|
|
136
|
+
() => {
|
|
137
|
+
failed = true;
|
|
138
|
+
setState("failed");
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return failed || search.scope !== undefined || !query.trim() ? Promise.resolve() : settled;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
export const composerInsertMessages = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
insertFromCloud: "Insert from Cloud",
|
|
8
|
+
insertFromCloudHint: "Find something in your apps and insert it",
|
|
9
|
+
insertTag: ({ tag }: { tag: string }) => `Insert ${tag}`,
|
|
10
|
+
chooseFrom: ({ app }: { app: string }) => `Choose from ${app}`,
|
|
11
|
+
tagFromApp: ({ tag, app }: { tag: string; app: string }) => `${tag} from ${app}`,
|
|
12
|
+
searchTagFor: ({ tag, query }: { tag: string; query: string }) => `Search ${tag} for “${query}”`,
|
|
13
|
+
searchTag: ({ tag }: { tag: string }) => `Search ${tag}`,
|
|
14
|
+
fromAppWithPreview: ({ app }: { app: string }) => `From ${app}, with preview`,
|
|
15
|
+
searchCloudFor: ({ query }: { query: string }) => `Search Cloud for “${query}”`,
|
|
16
|
+
searchAllApps: "Search all apps for content",
|
|
17
|
+
},
|
|
18
|
+
de: {
|
|
19
|
+
insertFromCloud: "Aus Cloud einfügen",
|
|
20
|
+
insertFromCloudHint: "Etwas aus deinen Apps suchen und einfügen",
|
|
21
|
+
insertTag: ({ tag }) => `${tag} einfügen`,
|
|
22
|
+
chooseFrom: ({ app }) => `Aus ${app} wählen`,
|
|
23
|
+
tagFromApp: ({ tag, app }) => `${tag} aus ${app}`,
|
|
24
|
+
searchTagFor: ({ tag, query }) => `„${query}“ in ${tag} suchen`,
|
|
25
|
+
searchTag: ({ tag }) => `${tag} durchsuchen`,
|
|
26
|
+
fromAppWithPreview: ({ app }) => `Aus ${app}, mit Vorschau`,
|
|
27
|
+
searchCloudFor: ({ query }) => `„${query}“ in Cloud suchen`,
|
|
28
|
+
searchAllApps: "In allen Apps nach Inhalten suchen",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ChatAttachment, ChatCommand, ChatCommandResult, ChatCommandScope } from "@k2b/ui";
|
|
2
|
+
import type { CloudResourceRef } from "../contracts";
|
|
3
|
+
import type { CloudSearchTag } from "./composer-catalog";
|
|
4
|
+
import { composerInsertMessages } from "./composer-insert-messages";
|
|
5
|
+
import { type CloudResourcePickerItem, openCloudResourcePicker } from "./resource-picker";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
type CloudComposerCatalog,
|
|
9
|
+
type CloudComposerCatalogState,
|
|
10
|
+
type CloudComposerCommand,
|
|
11
|
+
type CloudSearchTag,
|
|
12
|
+
createCloudComposerCatalog,
|
|
13
|
+
loadCloudComposerCatalog,
|
|
14
|
+
} from "./composer-catalog";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
type CloudCommandMenu,
|
|
18
|
+
type CloudCommandMenuOptions,
|
|
19
|
+
cloudCommandMenu,
|
|
20
|
+
type OpenComposerCommandOptions,
|
|
21
|
+
openComposerCommand,
|
|
22
|
+
} from "./composer-open";
|
|
23
|
+
|
|
24
|
+
export type CloudInsertMenuOptions = {
|
|
25
|
+
locale: string;
|
|
26
|
+
/** Every app's search tags, from `loadCloudComposerCatalog`. */
|
|
27
|
+
tags: readonly CloudSearchTag[];
|
|
28
|
+
/** Section of the rows in the composer's slash list. */
|
|
29
|
+
section?: string;
|
|
30
|
+
/** Turns the chosen resource into the reference the composer puts where `/…` was. */
|
|
31
|
+
mention: (item: CloudResourcePickerItem) => ChatAttachment;
|
|
32
|
+
/** References the draft already holds; the picker leaves them out. Read when the picker opens. */
|
|
33
|
+
excludeRefs?: () => readonly CloudResourceRef[];
|
|
34
|
+
/** Why nothing can be inserted now, such as a full message; it replaces each row's description. */
|
|
35
|
+
reason?: string;
|
|
36
|
+
/** First words of the composer's own scopes. A tag never claims them, so they keep narrowing as before. */
|
|
37
|
+
reservedWords?: readonly string[];
|
|
38
|
+
/** More first words for one tag, keyed `appId:tag`, such as a short word an app uses for its own tag. */
|
|
39
|
+
scopeWords?: Readonly<Record<string, readonly string[]>>;
|
|
40
|
+
/**
|
|
41
|
+
* Tags, keyed `appId:tag`, whose resources of one type this composer reads itself, such as
|
|
42
|
+
* `{ "assistant:studio-app": "assistant.app" }`. Their picker lists only that type, also without a Cloud reader, even
|
|
43
|
+
* after the person removes the scope chip. Every other picker lists only resources with a reader (`requireReader`).
|
|
44
|
+
*/
|
|
45
|
+
ownReaders?: Readonly<Record<string, string>>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type CloudInsertMenu = {
|
|
49
|
+
/** "Insert from Cloud": the picker across all apps. */
|
|
50
|
+
insert: ChatCommand;
|
|
51
|
+
/** One row per search tag, only while searching. Enter opens the picker narrowed to the tag; Tab narrows the list. */
|
|
52
|
+
tags: ChatCommand[];
|
|
53
|
+
/** One scope per tag that keeps a first word of its own, titled like "Notes from Notebooks". */
|
|
54
|
+
scopes: ChatCommandScope[];
|
|
55
|
+
/** For the composer's `commandFallback`: "Search Notes for “q3”" in a tag scope, "Search Cloud for “q3”" otherwise. */
|
|
56
|
+
fallback: (search: { query: string; scope?: string }) => ChatCommand | undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const fold = (text: string) => text.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").replace(/ß/g, "ss");
|
|
60
|
+
const tagKey = (tag: Pick<CloudSearchTag, "appId" | "tag">) => `${tag.appId}:${tag.tag}`;
|
|
61
|
+
const scopeId = (tag: Pick<CloudSearchTag, "appId" | "tag">) => `cloud-tag:${tagKey(tag)}`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Slash-list rows that insert Cloud content the person can read. Every row opens the resource picker, narrowed on the
|
|
65
|
+
* server to an app and tag when it has one, and hands the chosen item back as a `mention` for the token's place.
|
|
66
|
+
*/
|
|
67
|
+
export const cloudInsertMenu = (options: CloudInsertMenuOptions): CloudInsertMenu => {
|
|
68
|
+
const t = composerInsertMessages.resolve([options.locale]).t;
|
|
69
|
+
const pick = async (tag: CloudSearchTag | undefined, query: string): Promise<ChatCommandResult> => {
|
|
70
|
+
const ownType = tag ? options.ownReaders?.[tagKey(tag)] : undefined;
|
|
71
|
+
const item = await openCloudResourcePicker({
|
|
72
|
+
title: tag ? t.insertTag({ tag: tag.title }) : t.insertFromCloud,
|
|
73
|
+
scope: tag ? { appId: tag.appId, tag: tag.tag, label: `${tag.title} · ${tag.appName}`, icon: tag.appIcon } : undefined,
|
|
74
|
+
query: query || undefined,
|
|
75
|
+
...(ownType ? { resourceType: ownType } : { requireReader: true }),
|
|
76
|
+
excludeRefs: options.excludeRefs?.(),
|
|
77
|
+
});
|
|
78
|
+
return item ? { mention: options.mention(item) } : "cancelled";
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const reserved = new Set((options.reservedWords ?? []).map(fold));
|
|
82
|
+
// The title comes first, so Tab writes the word the person reads ("notizen ").
|
|
83
|
+
const candidates = options.tags.map((tag) => {
|
|
84
|
+
const title = fold(tag.title);
|
|
85
|
+
return [
|
|
86
|
+
...new Set(
|
|
87
|
+
[...(/\s/.test(title) ? [] : [title]), tag.tag, ...tag.aliases, ...(options.scopeWords?.[tagKey(tag)] ?? [])]
|
|
88
|
+
.map(fold)
|
|
89
|
+
.filter((word) => word && !/\s/.test(word) && !reserved.has(word)),
|
|
90
|
+
),
|
|
91
|
+
];
|
|
92
|
+
});
|
|
93
|
+
// A word two tags share narrows to neither, so Tab on either row would leave the list unscoped; neither keeps it.
|
|
94
|
+
const uses = new Map<string, number>();
|
|
95
|
+
for (const word of candidates.flat()) uses.set(word, (uses.get(word) ?? 0) + 1);
|
|
96
|
+
const scoped = new Map<string, CloudSearchTag>();
|
|
97
|
+
const scopes: ChatCommandScope[] = [];
|
|
98
|
+
const tags = options.tags.map((tag, index): ChatCommand => {
|
|
99
|
+
const words = candidates[index]!.filter((word) => uses.get(word) === 1);
|
|
100
|
+
if (words.length) {
|
|
101
|
+
scoped.set(scopeId(tag), tag);
|
|
102
|
+
scopes.push({ id: scopeId(tag), title: t.tagFromApp({ tag: tag.title, app: tag.appName }), words });
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
id: scopeId(tag),
|
|
106
|
+
section: options.section,
|
|
107
|
+
name: tag.tag,
|
|
108
|
+
label: tag.title,
|
|
109
|
+
description: t.chooseFrom({ app: tag.appName }),
|
|
110
|
+
icon: tag.appIcon,
|
|
111
|
+
keywords: [tag.title, tag.appName, tag.tag, ...tag.aliases, ...(options.scopeWords?.[tagKey(tag)] ?? [])],
|
|
112
|
+
app: tag.appName,
|
|
113
|
+
scope: words.length ? scopeId(tag) : undefined,
|
|
114
|
+
hiddenUntilSearch: true,
|
|
115
|
+
reason: options.reason,
|
|
116
|
+
action: () => pick(tag, ""),
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
insert: {
|
|
122
|
+
id: "cloud-insert",
|
|
123
|
+
section: options.section,
|
|
124
|
+
name: "cloud",
|
|
125
|
+
label: t.insertFromCloud,
|
|
126
|
+
description: t.insertFromCloudHint,
|
|
127
|
+
icon: "ti ti-cloud-search",
|
|
128
|
+
keywords: ["cloud", "insert", "einfügen", "inhalt", "content", "ressource", "resource", "verweis", "reference", "link"],
|
|
129
|
+
reason: options.reason,
|
|
130
|
+
action: () => pick(undefined, ""),
|
|
131
|
+
},
|
|
132
|
+
tags,
|
|
133
|
+
scopes,
|
|
134
|
+
fallback: ({ query, scope }) => {
|
|
135
|
+
if (scope !== undefined) {
|
|
136
|
+
const tag = scoped.get(scope);
|
|
137
|
+
if (!tag) return undefined;
|
|
138
|
+
return {
|
|
139
|
+
id: `cloud-search:${tagKey(tag)}`,
|
|
140
|
+
name: `cloud-search:${tagKey(tag)}`,
|
|
141
|
+
label: query ? t.searchTagFor({ tag: tag.title, query }) : t.searchTag({ tag: tag.title }),
|
|
142
|
+
description: t.fromAppWithPreview({ app: tag.appName }),
|
|
143
|
+
icon: "ti ti-search",
|
|
144
|
+
reason: options.reason,
|
|
145
|
+
action: () => pick(tag, query),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!query) return undefined;
|
|
149
|
+
return {
|
|
150
|
+
id: "cloud-search",
|
|
151
|
+
name: "cloud-search",
|
|
152
|
+
label: t.searchCloudFor({ query }),
|
|
153
|
+
description: t.searchAllApps,
|
|
154
|
+
icon: "ti ti-search",
|
|
155
|
+
reason: options.reason,
|
|
156
|
+
action: () => pick(undefined, query),
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
export const composerOpenMessages = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
appUnavailable: ({ app }: { app: string }) => `${app} is not reachable right now. Choose it again to try once more.`,
|
|
8
|
+
commandsFailed: "Commands from apps could not be loaded",
|
|
9
|
+
tryAgain: "Try again",
|
|
10
|
+
},
|
|
11
|
+
de: {
|
|
12
|
+
appUnavailable: ({ app }) => `${app} ist gerade nicht erreichbar. Erneut wählen versucht es nochmal.`,
|
|
13
|
+
commandsFailed: "Befehle aus Apps konnten nicht geladen werden",
|
|
14
|
+
tryAgain: "Erneut versuchen",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ChatCommand, ChatCommandDraft, ChatCommandOpenResult } from "@k2b/ui";
|
|
2
|
+
import { CAPABILITY_FRAMEWORK_ERROR_CODES } from "../contracts/capabilities";
|
|
3
|
+
import { isCommandPath } from "../contracts/command-path";
|
|
4
|
+
import { openCommand } from "./commands";
|
|
5
|
+
import type { CloudComposerCatalogState, CloudComposerCommand } from "./composer-catalog";
|
|
6
|
+
import { composerOpenMessages } from "./composer-open-messages";
|
|
7
|
+
|
|
8
|
+
export type OpenComposerCommandOptions = {
|
|
9
|
+
before: () => Promise<void>;
|
|
10
|
+
returnTo: () => string | undefined;
|
|
11
|
+
/** Read after `before`; once it aborts, for example because the person left the chat, nothing opens. */
|
|
12
|
+
signal?: () => AbortSignal | undefined;
|
|
13
|
+
locale: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Save first, then resolve and open. If nothing opens, cancel so the composer keeps its token and restores the live draft.
|
|
18
|
+
* Save errors reach the status line; resolution errors stay on the retryable row.
|
|
19
|
+
*/
|
|
20
|
+
export const openComposerCommand = async (
|
|
21
|
+
command: CloudComposerCommand,
|
|
22
|
+
options: OpenComposerCommandOptions,
|
|
23
|
+
): Promise<ChatCommandOpenResult> => {
|
|
24
|
+
await options.before();
|
|
25
|
+
const signal = options.signal?.();
|
|
26
|
+
if (signal?.aborted) return "cancelled";
|
|
27
|
+
const returnTo = options.returnTo();
|
|
28
|
+
if (returnTo !== undefined && !isCommandPath(returnTo)) throw new Error("Invalid Command return path");
|
|
29
|
+
try {
|
|
30
|
+
if (!(await openCommand(command.id, command.input, { returnTo }, signal))) return "cancelled";
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (signal?.aborted) return "cancelled";
|
|
33
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
34
|
+
if (
|
|
35
|
+
typeof cause === "object" &&
|
|
36
|
+
cause !== null &&
|
|
37
|
+
"code" in cause &&
|
|
38
|
+
typeof cause.code === "string" &&
|
|
39
|
+
"status" in cause &&
|
|
40
|
+
typeof cause.status === "number" &&
|
|
41
|
+
"message" in cause &&
|
|
42
|
+
typeof cause.message === "string" &&
|
|
43
|
+
cause.code !== CAPABILITY_FRAMEWORK_ERROR_CODES.appUnavailable &&
|
|
44
|
+
![502, 503, 504].includes(cause.status) &&
|
|
45
|
+
cause.message
|
|
46
|
+
) {
|
|
47
|
+
return { failed: cause.message };
|
|
48
|
+
}
|
|
49
|
+
return { failed: composerOpenMessages.resolve([options.locale]).t.appUnavailable({ app: command.appName }) };
|
|
50
|
+
}
|
|
51
|
+
if (signal?.aborted) return "cancelled";
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CloudCommandMenuOptions = {
|
|
55
|
+
locale: string;
|
|
56
|
+
commands: readonly CloudComposerCommand[];
|
|
57
|
+
state: CloudComposerCatalogState;
|
|
58
|
+
section?: string;
|
|
59
|
+
before: (draft: ChatCommandDraft, signal: AbortSignal, follow: () => void) => Promise<void>;
|
|
60
|
+
returnTo: () => string | undefined;
|
|
61
|
+
/** See `OpenComposerCommandOptions.signal`. */
|
|
62
|
+
signal?: () => AbortSignal | undefined;
|
|
63
|
+
};
|
|
64
|
+
export type CloudCommandMenu = { commands: ChatCommand[]; pending: boolean };
|
|
65
|
+
|
|
66
|
+
/** Put this section last. Pending placeholders reserve its end; a failed catalog offers one explicit reload. */
|
|
67
|
+
export const cloudCommandMenu = (options: CloudCommandMenuOptions): CloudCommandMenu => {
|
|
68
|
+
const t = composerOpenMessages.resolve([options.locale]).t;
|
|
69
|
+
if (options.state === "loading") return { commands: [], pending: true };
|
|
70
|
+
if (options.state === "failed")
|
|
71
|
+
return {
|
|
72
|
+
commands: [
|
|
73
|
+
{
|
|
74
|
+
id: "cloud-commands-retry",
|
|
75
|
+
name: "cloud-commands-retry",
|
|
76
|
+
label: t.commandsFailed,
|
|
77
|
+
description: t.tryAgain,
|
|
78
|
+
icon: "ti ti-alert-circle",
|
|
79
|
+
searchAgain: true,
|
|
80
|
+
persistent: true,
|
|
81
|
+
section: options.section,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
pending: false,
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
commands: options.commands.map((command) => ({
|
|
88
|
+
id: `cloud-command:${command.id}`,
|
|
89
|
+
name: command.id,
|
|
90
|
+
label: command.title,
|
|
91
|
+
description: `${command.appName} · ${command.description}`,
|
|
92
|
+
icon: command.icon,
|
|
93
|
+
keywords: [command.title, ...command.keywords, command.appName, command.description],
|
|
94
|
+
app: command.appName,
|
|
95
|
+
section: options.section,
|
|
96
|
+
// The composer's signal aborts when the person closes the list; the opener's, when it leaves the chat.
|
|
97
|
+
open: (draft, closed, follow) =>
|
|
98
|
+
openComposerCommand(command, {
|
|
99
|
+
before: () => options.before(draft, closed, follow),
|
|
100
|
+
returnTo: options.returnTo,
|
|
101
|
+
signal: () => {
|
|
102
|
+
const left = options.signal?.();
|
|
103
|
+
return left ? AbortSignal.any([closed, left]) : closed;
|
|
104
|
+
},
|
|
105
|
+
locale: options.locale,
|
|
106
|
+
}),
|
|
107
|
+
})),
|
|
108
|
+
pending: false,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CapabilityCommandManifest } from "../contracts/capabilities";
|
|
3
|
+
|
|
4
|
+
/** Shared discovery rule for the palette and composers; it grants no access to the destination. */
|
|
5
|
+
export const discoverableCommandInput = (command: CapabilityCommandManifest, appId: string, appIds: ReadonlySet<string>) =>
|
|
6
|
+
appIds.has(appId) ? z.fromJSONSchema(structuredClone(command.inputSchema)).safeParse({}) : undefined;
|