@k2b/cloud 0.39.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 +8 -4
- package/src/_internal/app-favicon.ts +4 -2
- 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 +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- 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.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -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/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 +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- 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 +2 -0
- package/src/ai/migrate.ts +11 -2
- 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/routes.ts +145 -1
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- 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-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 +2 -0
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- 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 +6 -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/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -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 +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- 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 +120 -13
- 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/seen-hints.ts +35 -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/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -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 +11 -0
- 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/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/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/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -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 +10 -2
- package/src/ssr/Layout.tsx +25 -2
- 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/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- 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 +2 -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 +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/effects.css +198 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- 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 +17 -22
|
@@ -7,11 +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,
|
|
13
14
|
type AppApprovalAccept,
|
|
15
|
+
type AppApprovalPendingAccept,
|
|
16
|
+
type AppDeviceChange,
|
|
17
|
+
AppDeviceChangeSchema,
|
|
14
18
|
AppDeviceCommandSchema,
|
|
19
|
+
type AppDeviceInbox,
|
|
20
|
+
AppDeviceInboxSchema,
|
|
21
|
+
type AppDeviceInboxWithChanges,
|
|
22
|
+
AppDeviceInboxWithChangesSchema,
|
|
15
23
|
AppDeviceProofSchema,
|
|
16
24
|
type AppDevicePublicKey,
|
|
17
25
|
AppDevicePublicKeySchema,
|
|
@@ -197,7 +205,8 @@ const connect = async (options: {
|
|
|
197
205
|
);
|
|
198
206
|
},
|
|
199
207
|
pairingResult: (payload: AppPairingPayload, key: AppApprovalKey, signal?: AbortSignal) => {
|
|
200
|
-
const p =
|
|
208
|
+
const p = AppPairingPayloadSchema.parse(payload);
|
|
209
|
+
if (p.issuer !== issuer) throw new AppApprovalClientError("INVALID_INPUT");
|
|
201
210
|
return request(
|
|
202
211
|
"/pairings/result",
|
|
203
212
|
AppPairingResultSchema,
|
|
@@ -205,8 +214,10 @@ const connect = async (options: {
|
|
|
205
214
|
signal,
|
|
206
215
|
);
|
|
207
216
|
},
|
|
208
|
-
pending: async (device: AppApprovalDevice, signal?: AbortSignal) => {
|
|
209
|
-
|
|
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);
|
|
210
221
|
if (!("requests" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
211
222
|
return result;
|
|
212
223
|
},
|
|
@@ -226,12 +237,46 @@ const connect = async (options: {
|
|
|
226
237
|
actions: info.features?.includes("actions") === true,
|
|
227
238
|
/** Whether this Cloud can include terminal consent in an explicitly opted-in inbox. */
|
|
228
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,
|
|
229
244
|
/** Pending sign-ins and the newest open Action approvals in one call; use it instead of `pending` where `actions` is true. */
|
|
230
|
-
inbox: async (
|
|
245
|
+
inbox: async (
|
|
246
|
+
device: AppApprovalDevice,
|
|
247
|
+
signal?: AbortSignal,
|
|
248
|
+
options?: { accept?: AppApprovalAccept[] },
|
|
249
|
+
): Promise<AppDeviceInbox | AppDeviceInboxWithChanges> => {
|
|
231
250
|
// SDK upgrades alone must not expose consent the app cannot show. Old Clouds never see the new key.
|
|
232
251
|
const accept = options?.accept?.filter((entry) => info.features?.includes(entry));
|
|
233
252
|
const result = await command(device, { operation: "inbox", ...(accept?.length ? { accept } : {}) }, signal);
|
|
234
|
-
if (!("actions" in result))
|
|
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");
|
|
235
280
|
return result;
|
|
236
281
|
},
|
|
237
282
|
/** One open Action approval. The digest is recomputed here, so a view that does not match it is never returned. */
|
|
@@ -263,6 +308,10 @@ const connect = async (options: {
|
|
|
263
308
|
if (!("state" in result) || result.state !== "updated") throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
264
309
|
return result;
|
|
265
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
|
+
*/
|
|
266
315
|
revoke: async (device: AppApprovalDevice, signal?: AbortSignal) => {
|
|
267
316
|
const result = await command(device, { operation: "revoke" }, signal);
|
|
268
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. */
|
|
@@ -2,6 +2,8 @@ import type { ChatCommandSearch } from "@k2b/ui";
|
|
|
2
2
|
import { createSignal } from "solid-js";
|
|
3
3
|
import { listCapabilityCatalog } from "../capabilities/client";
|
|
4
4
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
5
|
+
import { discoverableCommandInput } from "./discoverable-command";
|
|
6
|
+
import { navigationApps } from "./navigation-apps";
|
|
5
7
|
|
|
6
8
|
export type CloudSearchTag = {
|
|
7
9
|
appId: string;
|
|
@@ -13,12 +15,25 @@ export type CloudSearchTag = {
|
|
|
13
15
|
aliases: readonly string[];
|
|
14
16
|
};
|
|
15
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
|
+
|
|
16
30
|
/**
|
|
17
31
|
* What composers read from the page's one Capability catalog load. It leaves out the tags of apps without a resource
|
|
18
32
|
* reader: a picker that requires one can show none of their results. A composer that reads such a type itself adds its
|
|
19
33
|
* tag on its own and names the type in `cloudInsertMenu`'s `ownReaders`.
|
|
20
34
|
*/
|
|
21
|
-
export type CloudComposerCatalog = { tags: readonly CloudSearchTag[] };
|
|
35
|
+
export type CloudComposerCatalog = { tags: readonly CloudSearchTag[]; commands: readonly CloudComposerCommand[] };
|
|
36
|
+
export type CloudComposerCatalogState = "loading" | "ready" | "failed";
|
|
22
37
|
|
|
23
38
|
/** The budget of one Capability query (and of resolving a Command), applied to each catalog page. */
|
|
24
39
|
const CATALOG_PAGE_TIMEOUT_MS = 15_000;
|
|
@@ -27,6 +42,8 @@ const catalogs = new Map<string, Promise<CloudComposerCatalog>>();
|
|
|
27
42
|
|
|
28
43
|
const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
|
|
29
44
|
const tags: CloudSearchTag[] = [];
|
|
45
|
+
const commands: CloudComposerCommand[] = [];
|
|
46
|
+
const { appIds, currentAppId } = navigationApps();
|
|
30
47
|
const seen = new Set<string>();
|
|
31
48
|
let cursor: string | undefined;
|
|
32
49
|
do {
|
|
@@ -39,6 +56,23 @@ const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
|
|
|
39
56
|
});
|
|
40
57
|
if (!result.ok) throw new Error(result.error.message);
|
|
41
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
|
+
}
|
|
42
76
|
if (!app.manifest.types.some((type) => type.reader)) continue;
|
|
43
77
|
for (const query of app.manifest.queries) {
|
|
44
78
|
for (const tag of query.universalSearch?.tags ?? []) {
|
|
@@ -53,7 +87,7 @@ const loadCatalog = async (locale: string): Promise<CloudComposerCatalog> => {
|
|
|
53
87
|
if (next && cursor && next <= cursor) throw new Error("Invalid Composer catalog cursor");
|
|
54
88
|
cursor = next;
|
|
55
89
|
} while (cursor);
|
|
56
|
-
return { tags };
|
|
90
|
+
return { tags, commands };
|
|
57
91
|
};
|
|
58
92
|
|
|
59
93
|
/**
|
|
@@ -72,7 +106,7 @@ export const loadCloudComposerCatalog = (locale: string): Promise<CloudComposerC
|
|
|
72
106
|
};
|
|
73
107
|
|
|
74
108
|
/**
|
|
75
|
-
* The search tags of one composer. Call `ready` at the start of every `searchCommands` run: each menu opening starts
|
|
109
|
+
* The search tags, Commands, and load state of one composer. Call `ready` at the start of every `searchCommands` run: each menu opening starts
|
|
76
110
|
* the shared load once, so only the first opening, or one after a failure, sends a request, and no keystroke does.
|
|
77
111
|
* A search with typed text and no scope waits for that answer, so "/notiz" never settles as "nothing found" before its
|
|
78
112
|
* tag arrives. The overview and scoped searches do not wait, and after a failed load no search waits again; a later
|
|
@@ -80,20 +114,28 @@ export const loadCloudComposerCatalog = (locale: string): Promise<CloudComposerC
|
|
|
80
114
|
*/
|
|
81
115
|
export function createCloudComposerCatalog(load: (locale: string) => Promise<CloudComposerCatalog> = loadCloudComposerCatalog) {
|
|
82
116
|
const [tags, setTags] = createSignal<readonly CloudSearchTag[]>([]);
|
|
117
|
+
const [commands, setCommands] = createSignal<readonly CloudComposerCommand[]>([]);
|
|
118
|
+
const [state, setState] = createSignal<CloudComposerCatalogState>("loading");
|
|
83
119
|
let opening: number | undefined;
|
|
84
120
|
let settled: Promise<void> = Promise.resolve();
|
|
85
121
|
let failed = false;
|
|
86
122
|
return {
|
|
87
123
|
tags,
|
|
124
|
+
commands,
|
|
125
|
+
state,
|
|
88
126
|
ready(query: string, search: ChatCommandSearch, locale: string): Promise<void> {
|
|
89
127
|
if (opening !== search.opening) {
|
|
90
128
|
opening = search.opening;
|
|
129
|
+
if (state() === "failed") setState("loading");
|
|
91
130
|
settled = load(locale).then(
|
|
92
131
|
(catalog) => {
|
|
93
132
|
setTags(catalog.tags);
|
|
133
|
+
setCommands(catalog.commands);
|
|
134
|
+
setState("ready");
|
|
94
135
|
},
|
|
95
136
|
() => {
|
|
96
137
|
failed = true;
|
|
138
|
+
setState("failed");
|
|
97
139
|
},
|
|
98
140
|
);
|
|
99
141
|
}
|
|
@@ -6,11 +6,21 @@ import { type CloudResourcePickerItem, openCloudResourcePicker } from "./resourc
|
|
|
6
6
|
|
|
7
7
|
export {
|
|
8
8
|
type CloudComposerCatalog,
|
|
9
|
+
type CloudComposerCatalogState,
|
|
10
|
+
type CloudComposerCommand,
|
|
9
11
|
type CloudSearchTag,
|
|
10
12
|
createCloudComposerCatalog,
|
|
11
13
|
loadCloudComposerCatalog,
|
|
12
14
|
} from "./composer-catalog";
|
|
13
15
|
|
|
16
|
+
export {
|
|
17
|
+
type CloudCommandMenu,
|
|
18
|
+
type CloudCommandMenuOptions,
|
|
19
|
+
cloudCommandMenu,
|
|
20
|
+
type OpenComposerCommandOptions,
|
|
21
|
+
openComposerCommand,
|
|
22
|
+
} from "./composer-open";
|
|
23
|
+
|
|
14
24
|
export type CloudInsertMenuOptions = {
|
|
15
25
|
locale: string;
|
|
16
26
|
/** Every app's search tags, from `loadCloudComposerCatalog`. */
|
|
@@ -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;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { mergeSeenHints, parseSeenHintsCookieHeader, seenHintsSetCookie } from "../contracts/seen-hints";
|
|
2
|
+
|
|
3
|
+
/** Persist dismissal before navigating or closing the hint. */
|
|
4
|
+
export const markHintSeen = (id: string): void => {
|
|
5
|
+
document.cookie = seenHintsSetCookie(mergeSeenHints(parseSeenHintsCookieHeader(document.cookie), id), location.protocol === "https:");
|
|
6
|
+
};
|
|
@@ -10,6 +10,8 @@ export type LiveSubscriptionHandlers<T> = {
|
|
|
10
10
|
apply: (events: { data: T; cursor: string }[]) => Promise<void>;
|
|
11
11
|
/** Loads the canonical state again; events after it follow. */
|
|
12
12
|
resync: () => Promise<void>;
|
|
13
|
+
/** Reports changes in whether the subscription is confirmed and has no pending work. Never called after `close()`. */
|
|
14
|
+
caughtUp?: (caughtUp: boolean) => void;
|
|
13
15
|
/** The subscription ended because its resource is gone or no longer readable. */
|
|
14
16
|
revoked?: (code: "not_found" | "access_denied") => void;
|
|
15
17
|
/** Live updates stopped: the session ended, or `apply` or `resync` kept failing. Never called after `close()`. */
|
|
@@ -25,6 +27,7 @@ export type LiveConnection = {
|
|
|
25
27
|
type Subscriber = {
|
|
26
28
|
frame: () => unknown;
|
|
27
29
|
confirmed: boolean;
|
|
30
|
+
report: () => void;
|
|
28
31
|
receive: (message: LiveServerMessage) => void;
|
|
29
32
|
end: () => void;
|
|
30
33
|
};
|
|
@@ -56,9 +59,17 @@ const connect = (url: string, activity: "visible" | "always"): Shared => {
|
|
|
56
59
|
onOpen: (controls) => {
|
|
57
60
|
for (const subscriber of subscribers.values()) {
|
|
58
61
|
subscriber.confirmed = false;
|
|
62
|
+
subscriber.report();
|
|
59
63
|
controls.send(subscriber.frame());
|
|
60
64
|
}
|
|
61
65
|
},
|
|
66
|
+
onStatus: (status) => {
|
|
67
|
+
if (status === "open") return;
|
|
68
|
+
for (const subscriber of subscribers.values()) {
|
|
69
|
+
subscriber.confirmed = false;
|
|
70
|
+
subscriber.report();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
62
73
|
onMessage: (message) => {
|
|
63
74
|
if (message.t === "progress") {
|
|
64
75
|
for (const subscriber of subscribers.values()) if (subscriber.confirmed) subscriber.receive(message);
|
|
@@ -96,8 +107,16 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
|
|
|
96
107
|
let resyncAt: string | null = null;
|
|
97
108
|
let ended = false;
|
|
98
109
|
let running = false;
|
|
110
|
+
let caughtUp = false;
|
|
99
111
|
const work: Work<T>[] = [];
|
|
100
112
|
|
|
113
|
+
const report = () => {
|
|
114
|
+
const next = !ended && subscriber.confirmed && !running && work.length === 0;
|
|
115
|
+
if (next === caughtUp) return;
|
|
116
|
+
caughtUp = next;
|
|
117
|
+
handlers.caughtUp?.(next);
|
|
118
|
+
};
|
|
119
|
+
|
|
101
120
|
const attempt = async (run: () => Promise<void>): Promise<boolean> => {
|
|
102
121
|
for (let tries = 0; ; tries++) {
|
|
103
122
|
try {
|
|
@@ -122,6 +141,7 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
|
|
|
122
141
|
cursor = item.cursor;
|
|
123
142
|
continue;
|
|
124
143
|
}
|
|
144
|
+
report();
|
|
125
145
|
const applied = item.kind === "events" ? await attempt(() => handlers.apply(item.events)) : await attempt(handlers.resync);
|
|
126
146
|
if (!applied) {
|
|
127
147
|
// A closed subscription reports nothing: its owner ended it.
|
|
@@ -135,6 +155,7 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
|
|
|
135
155
|
}
|
|
136
156
|
} finally {
|
|
137
157
|
running = false;
|
|
158
|
+
report();
|
|
138
159
|
}
|
|
139
160
|
};
|
|
140
161
|
|
|
@@ -153,6 +174,7 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
|
|
|
153
174
|
return { t: "sub", id, channel, scope, ...(after ? { after } : {}) };
|
|
154
175
|
},
|
|
155
176
|
confirmed: false,
|
|
177
|
+
report,
|
|
156
178
|
receive: (message) => {
|
|
157
179
|
if (ended) return;
|
|
158
180
|
if (message.t === "revoked") {
|
|
@@ -189,10 +211,12 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
|
|
|
189
211
|
},
|
|
190
212
|
};
|
|
191
213
|
|
|
192
|
-
const stop = () => {
|
|
214
|
+
const stop = (notify = true) => {
|
|
193
215
|
if (ended) return;
|
|
194
216
|
ended = true;
|
|
195
217
|
work.length = 0;
|
|
218
|
+
if (notify) report();
|
|
219
|
+
else caughtUp = false;
|
|
196
220
|
if (subscribers.get(id) !== subscriber) return;
|
|
197
221
|
subscribers.delete(id);
|
|
198
222
|
socket.send({ t: "unsub", id });
|
|
@@ -205,6 +229,6 @@ export const liveConnection = (url: string, options: { activity?: "visible" | "a
|
|
|
205
229
|
subscribers.set(id, subscriber);
|
|
206
230
|
if (subscribers.size === 1) socket.connect();
|
|
207
231
|
else socket.send(subscriber.frame());
|
|
208
|
-
return { close: stop };
|
|
232
|
+
return { close: () => stop(false) };
|
|
209
233
|
},
|
|
210
234
|
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { readShellData } from "../ssr/shell-data";
|
|
2
|
+
|
|
3
|
+
/** Navigation controls discoverability, never authorization; the target application still checks access. */
|
|
4
|
+
export const navigationApps = (): { appIds: ReadonlySet<string>; currentAppId?: string } => {
|
|
5
|
+
const shell = readShellData();
|
|
6
|
+
return { appIds: new Set(shell?.apps.map((app) => app.id) ?? []), currentAppId: shell?.appId };
|
|
7
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CloudResourceRef } from "../contracts/capabilities";
|
|
2
|
+
import type { PanelPageContext } from "../contracts/panel-context";
|
|
3
|
+
|
|
4
|
+
export const PAGE_CONTEXT_COLLECT = "cloud.page-context.collect";
|
|
5
|
+
export const PAGE_CONTEXT_CHANGED = "cloud.page-context.changed";
|
|
6
|
+
export const PANEL_CONTEXT_CHANGED = "cloud.panel.context.changed";
|
|
7
|
+
export const PANEL_CONTEXT_ELEMENT = "cloud-panel-context";
|
|
8
|
+
export const PANEL_SHORTCUTS_ELEMENT = "cloud-panel-shortcuts";
|
|
9
|
+
export const PANEL_SHORTCUTS_CHANGED = "cloud.panel.shortcuts.changed";
|
|
10
|
+
export type PageContextSource = { scope?: CloudResourceRef };
|
|
11
|
+
export type PageContextCollection = { sources: PageContextSource[] };
|
|
12
|
+
export type PanelConsentRequest = { provider: string; allowed: boolean };
|
|
13
|
+
export type PanelState = { provider: string; appName: string; consent: boolean; allowed: boolean; saving: boolean; open: boolean };
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
interface WindowEventMap {
|
|
17
|
+
"cloud.panel.consent": CustomEvent<PanelConsentRequest>;
|
|
18
|
+
"cloud.panel.state": CustomEvent<PanelState>;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Framework-validated SSR data, then receiver-validated messages. Shared DOM state survives independent bundles. */
|
|
23
|
+
export function readProviderContext(): PanelPageContext | null {
|
|
24
|
+
if (typeof document === "undefined") return null;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(document.getElementById(PANEL_CONTEXT_ELEMENT)?.textContent ?? "null");
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isPanelContextMessage } from "../contracts/panel-context-plain";
|
|
2
|
+
import {
|
|
3
|
+
PANEL_CONTEXT_CHANGED,
|
|
4
|
+
PANEL_CONTEXT_ELEMENT,
|
|
5
|
+
PANEL_SHORTCUTS_CHANGED,
|
|
6
|
+
PANEL_SHORTCUTS_ELEMENT,
|
|
7
|
+
readProviderContext,
|
|
8
|
+
} from "./panel-context-bridge";
|
|
9
|
+
|
|
10
|
+
/** Installed only by an embedded PanelRuntime; the public page publisher never imports validation. */
|
|
11
|
+
export function installPanelContextReceiver(): () => void {
|
|
12
|
+
let element = document.getElementById(PANEL_CONTEXT_ELEMENT);
|
|
13
|
+
if (!element) {
|
|
14
|
+
element = document.createElement("script");
|
|
15
|
+
element.id = PANEL_CONTEXT_ELEMENT;
|
|
16
|
+
element.setAttribute("type", "application/json");
|
|
17
|
+
element.textContent = "null";
|
|
18
|
+
document.body.append(element);
|
|
19
|
+
}
|
|
20
|
+
let shortcutsElement = document.getElementById(PANEL_SHORTCUTS_ELEMENT);
|
|
21
|
+
if (!shortcutsElement) {
|
|
22
|
+
shortcutsElement = document.createElement("script");
|
|
23
|
+
shortcutsElement.id = PANEL_SHORTCUTS_ELEMENT;
|
|
24
|
+
shortcutsElement.setAttribute("type", "application/json");
|
|
25
|
+
shortcutsElement.textContent = "[]";
|
|
26
|
+
document.body.append(shortcutsElement);
|
|
27
|
+
}
|
|
28
|
+
const first = readProviderContext();
|
|
29
|
+
let pageId = first?.pageId;
|
|
30
|
+
let seq = first?.seq ?? 0;
|
|
31
|
+
const receive = (event: MessageEvent<unknown>) => {
|
|
32
|
+
if (event.source !== window.parent || event.origin !== location.origin || !isPanelContextMessage(event.data)) return;
|
|
33
|
+
const context = event.data.context;
|
|
34
|
+
if (context) {
|
|
35
|
+
if ((pageId !== undefined && context.pageId !== pageId) || context.seq < seq) return;
|
|
36
|
+
pageId = context.pageId;
|
|
37
|
+
seq = context.seq;
|
|
38
|
+
}
|
|
39
|
+
// Null revokes the current context without forgetting document identity or ordering.
|
|
40
|
+
const contextJson = JSON.stringify(context);
|
|
41
|
+
const contextChanged = element.textContent !== contextJson;
|
|
42
|
+
element.textContent = contextJson;
|
|
43
|
+
const shortcuts = JSON.stringify(context ? (event.data.shortcuts ?? []) : []);
|
|
44
|
+
const shortcutsChanged = shortcutsElement.textContent !== shortcuts;
|
|
45
|
+
shortcutsElement.textContent = shortcuts;
|
|
46
|
+
// Remember accepted snapshots, including empty ones, across Help remounts.
|
|
47
|
+
shortcutsElement.setAttribute("data-received", "");
|
|
48
|
+
if (shortcutsChanged) window.dispatchEvent(new Event(PANEL_SHORTCUTS_CHANGED));
|
|
49
|
+
if (contextChanged || !shortcutsChanged) window.dispatchEvent(new Event(PANEL_CONTEXT_CHANGED));
|
|
50
|
+
};
|
|
51
|
+
window.addEventListener("message", receive);
|
|
52
|
+
return () => window.removeEventListener("message", receive);
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Dropdown } from "@k2b/ui";
|
|
2
|
+
import { createSignal, onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { platformMessages } from "../ssr/platform-messages";
|
|
4
|
+
import type { PanelState } from "./panel-context-bridge";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The slot head's ⋯ menu for providers that need consent. The lazy slot controller mounts it over the server's
|
|
8
|
+
* placeholder button, so it costs no island and can never change an answer before the controller listens. Only
|
|
9
|
+
* document events cross the menu/controller boundary.
|
|
10
|
+
*/
|
|
11
|
+
export default function PanelMenu(props: { initial: PanelState; locale: string }) {
|
|
12
|
+
const t = () => platformMessages.resolve([props.locale]).t;
|
|
13
|
+
const [state, setState] = createSignal(props.initial);
|
|
14
|
+
const [open, setOpen] = createSignal(false);
|
|
15
|
+
onMount(() => {
|
|
16
|
+
const changed = (event: CustomEvent<PanelState>) => {
|
|
17
|
+
// A closed slot, another provider or one without consent leaves no reason for the menu to stay expanded.
|
|
18
|
+
if (!event.detail.open || event.detail.provider !== state().provider || !event.detail.consent) setOpen(false);
|
|
19
|
+
setState(event.detail);
|
|
20
|
+
};
|
|
21
|
+
window.addEventListener("cloud.panel.state", changed);
|
|
22
|
+
onCleanup(() => window.removeEventListener("cloud.panel.state", changed));
|
|
23
|
+
});
|
|
24
|
+
return (
|
|
25
|
+
<Dropdown.Root
|
|
26
|
+
open={open()}
|
|
27
|
+
onOpenChange={setOpen}
|
|
28
|
+
label={t().panelMenu}
|
|
29
|
+
position="bottom-left"
|
|
30
|
+
items={[
|
|
31
|
+
{
|
|
32
|
+
sectionLabel: t().panelFrom({ app: state().appName }),
|
|
33
|
+
items: [
|
|
34
|
+
{
|
|
35
|
+
label: t().panelShareContext,
|
|
36
|
+
choice: "checkbox",
|
|
37
|
+
// A getter: the menu keeps its rows, so a plain value would show the answer from when it opened.
|
|
38
|
+
checked: () => state().allowed,
|
|
39
|
+
disabled: state().saving,
|
|
40
|
+
action: () =>
|
|
41
|
+
window.dispatchEvent(
|
|
42
|
+
new CustomEvent("cloud.panel.consent", {
|
|
43
|
+
detail: { provider: state().provider, allowed: !state().allowed },
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
]}
|
|
50
|
+
>
|
|
51
|
+
<Dropdown.Trigger iconOnly label={t().panelMenu} disabled={!state().consent}>
|
|
52
|
+
<i class="ti ti-dots" aria-hidden="true" />
|
|
53
|
+
</Dropdown.Trigger>
|
|
54
|
+
</Dropdown.Root>
|
|
55
|
+
);
|
|
56
|
+
}
|