@k2b/cloud 0.39.0 → 0.41.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 +11 -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 +217 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +49 -18
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/setup-lock.ts +103 -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 +464 -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 +680 -0
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +456 -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 +198 -39
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +3 -0
- package/src/ai/migrate.ts +26 -2
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +195 -5
- 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 +245 -24
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +36 -8
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +43 -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-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-actions.ts +31 -7
- 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/schemas.ts +3 -0
- package/src/api/auth.ts +67 -12
- 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 +30 -3
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +159 -36
- 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 +166 -262
- package/src/approvals/approval-card.ts +68 -0
- 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 +123 -16
- 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/contracts/shared.ts +3 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +13 -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/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +49 -31
- package/src/services/approvals/index.ts +7 -5
- package/src/services/approvals/list.ts +27 -6
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/auth-flows/password-reset.ts +32 -6
- 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 +13 -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/pdf/markdown.ts +160 -2
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +296 -41
- 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/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +222 -419
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -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/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- 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 +21 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-idle.ts +9 -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/approvals.css +6 -0
- package/src/styles/effects.css +213 -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
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import { attachResizeSeparator, parkWorkspaceDetailDrawers } from "@k2b/ui";
|
|
1
|
+
import { attachResizeSeparator, LocaleProvider, parkWorkspaceDetailDrawers } from "@k2b/ui";
|
|
2
|
+
import { createComponent } from "solid-js";
|
|
3
|
+
import { render } from "solid-js/web";
|
|
2
4
|
import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, readPanelCookie, serializePanelCookie } from "../_internal/panel-cookie";
|
|
5
|
+
import { CloudResourceRefSchema } from "../contracts/capabilities";
|
|
6
|
+
import type { PanelPageContext } from "../contracts/panel-context";
|
|
7
|
+
import { isPanelShortcut, PANEL_CONTEXT_PARAM, panelProviderNeedsConsent, withPanelContext } from "../contracts/panel-context-plain";
|
|
8
|
+
import { helpShortcutGroups } from "../ssr/layout-help-start";
|
|
3
9
|
import { platformMessages } from "../ssr/platform-messages";
|
|
4
10
|
import { readShellData } from "../ssr/shell-data";
|
|
11
|
+
import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "./command-shortcuts";
|
|
5
12
|
import type { PanelRequest } from "./panel";
|
|
6
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
PAGE_CONTEXT_CHANGED,
|
|
15
|
+
PAGE_CONTEXT_COLLECT,
|
|
16
|
+
type PageContextCollection,
|
|
17
|
+
type PanelConsentRequest,
|
|
18
|
+
type PanelState,
|
|
19
|
+
} from "./panel-context-bridge";
|
|
20
|
+
import PanelMenu from "./panel-menu";
|
|
21
|
+
import { isPanelCloseMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
|
|
22
|
+
import { importOnDemand } from "./reload";
|
|
7
23
|
|
|
8
24
|
export const PANEL_READY_TIMEOUT_MS = 10_000;
|
|
9
25
|
let installed: { dispose: () => void; request: (request: PanelRequest) => void } | undefined;
|
|
@@ -20,7 +36,9 @@ export function installPanelSlot(): () => void {
|
|
|
20
36
|
const body = slot?.querySelector<HTMLElement>(".cloud-panel__body");
|
|
21
37
|
const panel = readShellData()?.panel;
|
|
22
38
|
if (!canvas || !slot || !body || !panel?.providers.length) return () => {};
|
|
23
|
-
const
|
|
39
|
+
const lang = document.documentElement.lang || "en";
|
|
40
|
+
const t = platformMessages.resolve([lang]).t;
|
|
41
|
+
const menuHost = slot.querySelector<HTMLElement>("[data-cloud-panel-menu]");
|
|
24
42
|
// The mode uses the same px breakpoint as panel.css and the shell, read fresh, so CSS and the controller always
|
|
25
43
|
// agree; `phoneChange` only tells when to look again.
|
|
26
44
|
const isPhone = () => !window.matchMedia("(min-width: 768px)").matches;
|
|
@@ -34,6 +52,190 @@ export function installPanelSlot(): () => void {
|
|
|
34
52
|
const grow = slot.querySelector<HTMLButtonElement>("[data-cloud-panel-grow]");
|
|
35
53
|
let provider = panel.providers.find((item) => item.id === slot.dataset.provider) ?? panel.providers[0]!;
|
|
36
54
|
let frame = slot.querySelector<HTMLIFrameElement>("[data-cloud-panel-frame]");
|
|
55
|
+
let disposed = false;
|
|
56
|
+
let context = panel.context ?? null;
|
|
57
|
+
let loadVersion = 0;
|
|
58
|
+
const answers = new Map<string, boolean | null>();
|
|
59
|
+
const revisions = new Map<string, number>();
|
|
60
|
+
if (panel.consent) {
|
|
61
|
+
answers.set(panel.consent.provider, panel.consent.allowed);
|
|
62
|
+
revisions.set(panel.consent.provider, panel.consent.revision);
|
|
63
|
+
}
|
|
64
|
+
const saving = new Set<string>();
|
|
65
|
+
const errors = new Set<string>();
|
|
66
|
+
const requests = new AbortController();
|
|
67
|
+
/*
|
|
68
|
+
* Consent reads and writes run one at a time, in order, and sharing with a provider that needs consent is blocked
|
|
69
|
+
* while any of them is pending or the answers are stale (a back/forward restore). Server revisions also fence
|
|
70
|
+
* writes still running after a client timeout; only a settled, current `true` lets context through.
|
|
71
|
+
*/
|
|
72
|
+
let consentQueue: Promise<void> = Promise.resolve();
|
|
73
|
+
let consentPending = 0;
|
|
74
|
+
let answersStale = false;
|
|
75
|
+
// A save that failed or timed out has an unknown outcome on the server: nothing is shared until the person answers again.
|
|
76
|
+
const allowed = () =>
|
|
77
|
+
!provider.consent || (consentPending === 0 && !answersStale && !errors.has(provider.id) && answers.get(provider.id) === true);
|
|
78
|
+
// Whether the current frame has seen a context, in its URL or by message; only such a frame needs a clearing null.
|
|
79
|
+
let disclosed = !!frame && new URL(frame.src, location.origin).searchParams.has(PANEL_CONTEXT_PARAM);
|
|
80
|
+
// The frame may have navigated on its own; only the selected provider's page below its panel path may receive context.
|
|
81
|
+
const showsProvider = () => {
|
|
82
|
+
try {
|
|
83
|
+
const shown = frame?.contentWindow?.location;
|
|
84
|
+
const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
|
|
85
|
+
return (
|
|
86
|
+
!!shown &&
|
|
87
|
+
frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id &&
|
|
88
|
+
(shown.pathname === scope || shown.pathname.startsWith(`${scope}/`))
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const shortcuts = () =>
|
|
95
|
+
helpShortcutGroups(contextCommandsWithShortcuts())
|
|
96
|
+
.flatMap((group) =>
|
|
97
|
+
group.shortcuts.map(({ id, title, description, shortcut, scope }) => ({
|
|
98
|
+
id,
|
|
99
|
+
title,
|
|
100
|
+
...(description ? { description } : {}),
|
|
101
|
+
shortcut,
|
|
102
|
+
...(scope === "selection" || scope === "page" ? { scope } : {}),
|
|
103
|
+
})),
|
|
104
|
+
)
|
|
105
|
+
.filter(isPanelShortcut);
|
|
106
|
+
let lastShortcutsJson: string | undefined;
|
|
107
|
+
const canSend = () => isOpen() && allowed() && !!context && slot.dataset.state === "ready" && !!frame?.contentWindow && showsProvider();
|
|
108
|
+
const sendContext = () => {
|
|
109
|
+
if (!canSend()) return;
|
|
110
|
+
const list = !panelProviderNeedsConsent(provider.id) ? shortcuts() : undefined;
|
|
111
|
+
frame?.contentWindow?.postMessage(
|
|
112
|
+
{ type: "cloud.panel.context", v: 1, context, ...(list ? { shortcuts: list } : {}) },
|
|
113
|
+
location.origin,
|
|
114
|
+
);
|
|
115
|
+
if (list) lastShortcutsJson = JSON.stringify(list);
|
|
116
|
+
disclosed = true;
|
|
117
|
+
// A reload of the provider page renders from its address, so the address follows the live context.
|
|
118
|
+
syncUrlContext(context);
|
|
119
|
+
};
|
|
120
|
+
const postNull = () => {
|
|
121
|
+
frame?.contentWindow?.postMessage({ type: "cloud.panel.context", v: 1, context: null }, location.origin);
|
|
122
|
+
lastShortcutsJson = undefined;
|
|
123
|
+
};
|
|
124
|
+
let shortcutsPending = false;
|
|
125
|
+
const commandsChanged = () => {
|
|
126
|
+
if (shortcutsPending) return;
|
|
127
|
+
shortcutsPending = true;
|
|
128
|
+
queueMicrotask(() => {
|
|
129
|
+
shortcutsPending = false;
|
|
130
|
+
if (disposed || panelProviderNeedsConsent(provider.id) || !canSend()) return;
|
|
131
|
+
if (JSON.stringify(shortcuts()) !== lastShortcutsJson) sendContext();
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
const clearContext = () => {
|
|
135
|
+
if (disclosed) postNull();
|
|
136
|
+
};
|
|
137
|
+
// The provider's address carries the snapshot its server renders on reload: it follows sharing in both directions.
|
|
138
|
+
// True when the provider's current address now matches; false while its document is not reachable yet.
|
|
139
|
+
function syncUrlContext(next: PanelPageContext | null): boolean {
|
|
140
|
+
try {
|
|
141
|
+
const shown = frame?.contentWindow;
|
|
142
|
+
if (!shown || (next && !showsProvider())) return false;
|
|
143
|
+
const current = `${shown.location.pathname}${shown.location.search}${shown.location.hash}`;
|
|
144
|
+
const path = withPanelContext(current, next);
|
|
145
|
+
if (path !== current) shown.history.replaceState(shown.history.state, "", path);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
// Not yet a same-origin document (about:blank, a pending navigation); its ready handshake clears it.
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// A withdrawn answer must not come back when the provider reloads its own document: the URL snapshot goes too.
|
|
153
|
+
const forgetUrlContext = () => syncUrlContext(null);
|
|
154
|
+
const stopSharing = () => {
|
|
155
|
+
if (!disclosed) return;
|
|
156
|
+
postNull();
|
|
157
|
+
// Only a frame whose address is clean holds nothing any more; otherwise ready cleans it up later.
|
|
158
|
+
if (forgetUrlContext()) disclosed = false;
|
|
159
|
+
};
|
|
160
|
+
const collectContext = () => {
|
|
161
|
+
if (!context) return;
|
|
162
|
+
const collection: PageContextCollection = { sources: [] };
|
|
163
|
+
window.dispatchEvent(new CustomEvent(PAGE_CONTEXT_COLLECT, { detail: collection }));
|
|
164
|
+
const source = collection.sources.findLast((source) => source.scope !== undefined);
|
|
165
|
+
const parsed = source ? CloudResourceRefSchema.safeParse(source.scope) : undefined;
|
|
166
|
+
const scope = source ? (parsed?.success ? parsed.data : undefined) : panel.context?.scope;
|
|
167
|
+
if (scope?.type === context.scope?.type && scope?.id === context.scope?.id) return;
|
|
168
|
+
const { scope: _previous, ...base } = context;
|
|
169
|
+
context = { ...base, seq: context.seq + 1, ...(scope ? { scope } : {}) };
|
|
170
|
+
sendContext();
|
|
171
|
+
};
|
|
172
|
+
// Share or stop sharing after every consent step; an open question or its absence follows in update().
|
|
173
|
+
const reconcile = () => {
|
|
174
|
+
if (disposed) return;
|
|
175
|
+
update();
|
|
176
|
+
if (allowed()) sendContext();
|
|
177
|
+
else if (provider.consent) stopSharing();
|
|
178
|
+
};
|
|
179
|
+
const enqueueConsent = (step: () => Promise<void>) => {
|
|
180
|
+
consentPending += 1;
|
|
181
|
+
reconcile();
|
|
182
|
+
const run = consentQueue.then(step).finally(() => {
|
|
183
|
+
consentPending -= 1;
|
|
184
|
+
reconcile();
|
|
185
|
+
});
|
|
186
|
+
consentQueue = run.catch(() => {});
|
|
187
|
+
return run;
|
|
188
|
+
};
|
|
189
|
+
// Replaces every answer of providers that need consent; a failed read leaves them unanswered (fail closed).
|
|
190
|
+
const readAnswersNow = async () => {
|
|
191
|
+
const next = new Map<string, boolean | null>();
|
|
192
|
+
const nextRevisions = new Map<string, number>();
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch("/api/me/panel-consent", {
|
|
195
|
+
cache: "no-store",
|
|
196
|
+
signal: AbortSignal.any([requests.signal, AbortSignal.timeout(PANEL_READY_TIMEOUT_MS)]),
|
|
197
|
+
});
|
|
198
|
+
if (!response.ok) throw new Error("Panel consent unavailable");
|
|
199
|
+
const data: unknown = await response.json();
|
|
200
|
+
if (data && typeof data === "object" && "answers" in data && data.answers && typeof data.answers === "object") {
|
|
201
|
+
for (const item of panel.providers) {
|
|
202
|
+
const value: unknown = Reflect.get(data.answers, item.id);
|
|
203
|
+
if (
|
|
204
|
+
value &&
|
|
205
|
+
typeof value === "object" &&
|
|
206
|
+
"allowed" in value &&
|
|
207
|
+
typeof value.allowed === "boolean" &&
|
|
208
|
+
"revision" in value &&
|
|
209
|
+
typeof value.revision === "number" &&
|
|
210
|
+
Number.isInteger(value.revision) &&
|
|
211
|
+
value.revision > 0
|
|
212
|
+
) {
|
|
213
|
+
next.set(item.id, value.allowed);
|
|
214
|
+
nextRevisions.set(item.id, value.revision);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// No answer is fail closed, including an unavailable store or an aborted request.
|
|
220
|
+
}
|
|
221
|
+
for (const item of panel.providers) {
|
|
222
|
+
if (!item.consent) continue;
|
|
223
|
+
answers.set(item.id, next.get(item.id) ?? null);
|
|
224
|
+
revisions.set(item.id, nextRevisions.get(item.id) ?? 0);
|
|
225
|
+
}
|
|
226
|
+
answersStale = false;
|
|
227
|
+
};
|
|
228
|
+
let answersRead: Promise<void> | undefined;
|
|
229
|
+
const readAnswers = () => {
|
|
230
|
+
answersRead ??= enqueueConsent(readAnswersNow);
|
|
231
|
+
return answersRead;
|
|
232
|
+
};
|
|
233
|
+
// A restored page may hold answers changed elsewhere meanwhile: stop sharing now and read them again in order.
|
|
234
|
+
const revalidate = () => {
|
|
235
|
+
answersStale = true;
|
|
236
|
+
answersRead = enqueueConsent(readAnswersNow);
|
|
237
|
+
return answersRead;
|
|
238
|
+
};
|
|
37
239
|
let search: string | undefined;
|
|
38
240
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
39
241
|
let opener: HTMLElement | undefined;
|
|
@@ -76,6 +278,38 @@ export function installPanelSlot(): () => void {
|
|
|
76
278
|
tab.setAttribute("aria-selected", String(selected));
|
|
77
279
|
tab.tabIndex = selected ? 0 : -1;
|
|
78
280
|
}
|
|
281
|
+
slot.dataset.appName = provider.appName;
|
|
282
|
+
slot.dataset.providerConsent = String(provider.consent);
|
|
283
|
+
slot.dataset.contextAllowed = String(allowed());
|
|
284
|
+
slot.dataset.consentSaving = String(saving.has(provider.id));
|
|
285
|
+
const ask =
|
|
286
|
+
isOpen() && provider.consent && ((answers.has(provider.id) && answers.get(provider.id) === null) || errors.has(provider.id));
|
|
287
|
+
if (ask) slot.dataset.consent = "ask";
|
|
288
|
+
else delete slot.dataset.consent;
|
|
289
|
+
const title = slot.querySelector<HTMLElement>("[data-cloud-panel-consent-title]");
|
|
290
|
+
const text = slot.querySelector<HTMLElement>("[data-cloud-panel-consent-body]");
|
|
291
|
+
const error = slot.querySelector<HTMLElement>("[data-cloud-panel-consent-error]");
|
|
292
|
+
if (title) title.textContent = t.panelConsentTitle({ app: provider.appName });
|
|
293
|
+
if (text) text.textContent = t.panelConsentBody({ app: provider.appName });
|
|
294
|
+
if (error) error.textContent = errors.has(provider.id) ? t.panelConsentSaveError : "";
|
|
295
|
+
for (const button of slot.querySelectorAll<HTMLButtonElement>("[data-cloud-panel-consent-answer]"))
|
|
296
|
+
button.disabled = saving.has(provider.id);
|
|
297
|
+
if (menuHost) {
|
|
298
|
+
menuHost.style.visibility = provider.consent ? "visible" : "hidden";
|
|
299
|
+
menuHost.inert = !provider.consent;
|
|
300
|
+
}
|
|
301
|
+
window.dispatchEvent(
|
|
302
|
+
new CustomEvent<PanelState>("cloud.panel.state", {
|
|
303
|
+
detail: {
|
|
304
|
+
provider: provider.id,
|
|
305
|
+
appName: provider.appName,
|
|
306
|
+
consent: provider.consent,
|
|
307
|
+
allowed: allowed(),
|
|
308
|
+
saving: saving.has(provider.id),
|
|
309
|
+
open: isOpen(),
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
79
313
|
const description = slot.querySelector<HTMLElement>(".k2b-placeholder__description");
|
|
80
314
|
if (description) description.textContent = t.panelNotResponding({ label: provider.label });
|
|
81
315
|
};
|
|
@@ -96,17 +330,41 @@ export function installPanelSlot(): () => void {
|
|
|
96
330
|
clearTimer();
|
|
97
331
|
slot.dataset.state = "ready";
|
|
98
332
|
if (status) status.textContent = "";
|
|
333
|
+
if (allowed()) {
|
|
334
|
+
sendContext();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// The arriving document may carry a snapshot this controller never cleaned (a navigation that was already under
|
|
338
|
+
// way at withdrawal, a reload): look at the document itself, not only at what the last one saw.
|
|
339
|
+
let carries = disclosed;
|
|
340
|
+
try {
|
|
341
|
+
carries ||= new URL(frame?.contentWindow?.location.href ?? "about:blank").searchParams.has(PANEL_CONTEXT_PARAM);
|
|
342
|
+
} catch {
|
|
343
|
+
carries = true;
|
|
344
|
+
}
|
|
345
|
+
if (carries) {
|
|
346
|
+
postNull();
|
|
347
|
+
disclosed = !forgetUrlContext();
|
|
348
|
+
}
|
|
99
349
|
};
|
|
100
350
|
const unload = () => {
|
|
351
|
+
++loadVersion;
|
|
352
|
+
focusPending = false;
|
|
353
|
+
clearContext();
|
|
101
354
|
clearTimer();
|
|
102
355
|
frame?.remove();
|
|
103
356
|
frame = null;
|
|
357
|
+
lastShortcutsJson = undefined;
|
|
104
358
|
slot.dataset.state = "closed";
|
|
105
359
|
if (status) status.textContent = "";
|
|
106
360
|
};
|
|
107
361
|
const target = () => {
|
|
108
362
|
const url = new URL(provider.href, location.origin);
|
|
109
363
|
if (search !== undefined) url.search = search;
|
|
364
|
+
if (url.origin === location.origin) {
|
|
365
|
+
const path = withPanelContext(`${url.pathname}${url.search}${url.hash}`, allowed() ? context : null);
|
|
366
|
+
return new URL(path, location.origin);
|
|
367
|
+
}
|
|
110
368
|
return url;
|
|
111
369
|
};
|
|
112
370
|
// The page the iframe shows now; a reader may have moved on inside the panel since it loaded.
|
|
@@ -117,10 +375,17 @@ export function installPanelSlot(): () => void {
|
|
|
117
375
|
return undefined;
|
|
118
376
|
}
|
|
119
377
|
};
|
|
120
|
-
|
|
378
|
+
// The context snapshot in the URL is not the provider's destination: a frame that got newer context live stays.
|
|
379
|
+
const destination = (href: string) => {
|
|
380
|
+
const url = new URL(href, location.origin);
|
|
381
|
+
return url.origin === location.origin ? withPanelContext(`${url.pathname}${url.search}${url.hash}`, null) : url.href;
|
|
382
|
+
};
|
|
383
|
+
const loadNow = (replace = false) => {
|
|
121
384
|
const url = target();
|
|
122
|
-
if (!replace && frame
|
|
385
|
+
if (!replace && frame && destination(frame.src) === destination(url.href)) return;
|
|
386
|
+
clearContext();
|
|
123
387
|
frame?.remove();
|
|
388
|
+
lastShortcutsJson = undefined;
|
|
124
389
|
// The server validates every provider path; the iframe still never leaves this origin.
|
|
125
390
|
if (url.origin !== location.origin) {
|
|
126
391
|
frame = null;
|
|
@@ -133,9 +398,98 @@ export function installPanelSlot(): () => void {
|
|
|
133
398
|
frame.title = provider.label;
|
|
134
399
|
frame.dataset.cloudPanelFrame = provider.id;
|
|
135
400
|
frame.src = url.href;
|
|
401
|
+
disclosed = url.searchParams.has(PANEL_CONTEXT_PARAM);
|
|
136
402
|
loading();
|
|
137
403
|
body.append(frame);
|
|
138
404
|
};
|
|
405
|
+
const load = (replace = false) => {
|
|
406
|
+
const version = ++loadVersion;
|
|
407
|
+
if (provider.consent && !answers.has(provider.id)) {
|
|
408
|
+
clearContext();
|
|
409
|
+
frame?.remove();
|
|
410
|
+
frame = null;
|
|
411
|
+
lastShortcutsJson = undefined;
|
|
412
|
+
loading();
|
|
413
|
+
clearTimer(); // The consent read has its own bounded timeout; provider readiness starts with its load.
|
|
414
|
+
void readAnswers().then(() => {
|
|
415
|
+
if (disposed || !isOpen() || version !== loadVersion) return;
|
|
416
|
+
loadNow(replace);
|
|
417
|
+
update();
|
|
418
|
+
// open() could not move focus while nothing was there; do it now unless the reader moved on meanwhile.
|
|
419
|
+
const active = document.activeElement;
|
|
420
|
+
if (focusPending && (active === document.body || active === opener || slot.contains(active))) focusPanel();
|
|
421
|
+
focusPending = false;
|
|
422
|
+
});
|
|
423
|
+
} else loadNow(replace);
|
|
424
|
+
};
|
|
425
|
+
const saveConsent = (request: PanelConsentRequest) => {
|
|
426
|
+
const item = panel.providers.find((item) => item.id === request.provider && item.consent);
|
|
427
|
+
if (!item || typeof request.allowed !== "boolean" || saving.has(item.id)) return;
|
|
428
|
+
saving.add(item.id);
|
|
429
|
+
errors.delete(item.id);
|
|
430
|
+
const questionHadFocus = slot.querySelector("[data-cloud-panel-consent]")?.contains(document.activeElement);
|
|
431
|
+
// Enqueueing blocks sharing at once, so a withdrawal stops disclosure before its save even starts.
|
|
432
|
+
// keepalive: leaving the page does not cancel the save. A save the browser gives up on (timeout) is rolled back
|
|
433
|
+
// by the server, so no answer lands after this page stopped waiting for it.
|
|
434
|
+
const put = () =>
|
|
435
|
+
fetch(`/api/me/panel-consent/${encodeURIComponent(item.id)}`, {
|
|
436
|
+
method: "PUT",
|
|
437
|
+
headers: { "Content-Type": "application/json" },
|
|
438
|
+
body: JSON.stringify({ allowed: request.allowed, revision: revisions.get(item.id) ?? 0 }),
|
|
439
|
+
keepalive: true,
|
|
440
|
+
signal: AbortSignal.timeout(PANEL_READY_TIMEOUT_MS),
|
|
441
|
+
});
|
|
442
|
+
void enqueueConsent(async () => {
|
|
443
|
+
try {
|
|
444
|
+
let response = await put();
|
|
445
|
+
// Changed meanwhile (another tab, or a read that failed): the person's choice now is the newest one, so read
|
|
446
|
+
// the current revision and save it once more. A second conflict shows the question again.
|
|
447
|
+
if (response.status === 409) {
|
|
448
|
+
await readAnswersNow();
|
|
449
|
+
response = await put();
|
|
450
|
+
}
|
|
451
|
+
if (!response.ok) throw new Error("Panel consent was not saved");
|
|
452
|
+
const saved: unknown = await response.json();
|
|
453
|
+
if (
|
|
454
|
+
!saved ||
|
|
455
|
+
typeof saved !== "object" ||
|
|
456
|
+
!("provider" in saved) ||
|
|
457
|
+
saved.provider !== item.id ||
|
|
458
|
+
!("allowed" in saved) ||
|
|
459
|
+
saved.allowed !== request.allowed ||
|
|
460
|
+
!("revision" in saved) ||
|
|
461
|
+
typeof saved.revision !== "number" ||
|
|
462
|
+
!Number.isInteger(saved.revision) ||
|
|
463
|
+
saved.revision <= 0
|
|
464
|
+
)
|
|
465
|
+
throw new Error("Invalid panel consent answer");
|
|
466
|
+
answers.set(item.id, request.allowed);
|
|
467
|
+
revisions.set(item.id, saved.revision);
|
|
468
|
+
} catch {
|
|
469
|
+
// Not saved: nothing is shared until the person answers again; the read learns the current revision.
|
|
470
|
+
errors.add(item.id);
|
|
471
|
+
await readAnswersNow();
|
|
472
|
+
answers.set(item.id, null);
|
|
473
|
+
} finally {
|
|
474
|
+
saving.delete(item.id);
|
|
475
|
+
}
|
|
476
|
+
}).then(() => {
|
|
477
|
+
if (disposed) return;
|
|
478
|
+
// Move on into the provider only if the reader is still at the question (or nowhere), not after they moved on.
|
|
479
|
+
const active = document.activeElement;
|
|
480
|
+
const stillHere = active === document.body || !!slot.querySelector("[data-cloud-panel-consent]")?.contains(active);
|
|
481
|
+
if (provider.id === item.id && isOpen() && questionHadFocus && stillHere && slot.dataset.consent !== "ask") frame?.focus();
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
const consentRequest = (event: CustomEvent<PanelConsentRequest>) => {
|
|
485
|
+
void saveConsent(event.detail);
|
|
486
|
+
};
|
|
487
|
+
let focusPending = false;
|
|
488
|
+
// An open question takes focus first, so the reader hears it before the provider page.
|
|
489
|
+
const focusPanel = () => {
|
|
490
|
+
if (slot.dataset.consent === "ask") slot.querySelector<HTMLElement>("[data-cloud-panel-consent]")?.focus();
|
|
491
|
+
else frame?.focus();
|
|
492
|
+
};
|
|
139
493
|
let removeFallbackFocus = () => {};
|
|
140
494
|
const returnFocus = () => {
|
|
141
495
|
if (opener?.isConnected && opener.getClientRects().length && !opener.closest("[inert]")) {
|
|
@@ -172,6 +526,7 @@ export function installPanelSlot(): () => void {
|
|
|
172
526
|
parkWorkspaceDetailDrawers(document);
|
|
173
527
|
const active = document.activeElement;
|
|
174
528
|
if (active instanceof HTMLElement && active !== document.body && !slot.contains(active)) opener = active;
|
|
529
|
+
if (provider.id !== next.id) unload();
|
|
175
530
|
provider = next;
|
|
176
531
|
search = typeof nextSearch === "string" && nextSearch.length <= 2048 ? nextSearch : undefined;
|
|
177
532
|
canvas.setAttribute(attribute(), "open");
|
|
@@ -180,10 +535,12 @@ export function installPanelSlot(): () => void {
|
|
|
180
535
|
seedWidth();
|
|
181
536
|
// A requested search is a destination: it replaces a page reached inside the panel.
|
|
182
537
|
const shown = shownHref();
|
|
183
|
-
|
|
538
|
+
focusPending = false;
|
|
539
|
+
load(search !== undefined && shown !== undefined && shown !== "about:blank" && destination(shown) !== destination(target().href));
|
|
184
540
|
writeCookie();
|
|
185
541
|
update();
|
|
186
|
-
frame
|
|
542
|
+
if (frame) focusPanel();
|
|
543
|
+
else focusPending = true;
|
|
187
544
|
};
|
|
188
545
|
const request = (request: PanelRequest) => {
|
|
189
546
|
if (request.action === "open") open(request.provider, request.search);
|
|
@@ -194,10 +551,16 @@ export function installPanelSlot(): () => void {
|
|
|
194
551
|
if (!frame || event.source !== frame.contentWindow || event.origin !== location.origin) return;
|
|
195
552
|
if (isPanelReadyMessage(event.data, provider.id)) markReady();
|
|
196
553
|
else if (isPanelCloseMessage(event.data)) close();
|
|
554
|
+
else if (isPanelSearchMessage(event.data)) {
|
|
555
|
+
const command = contextCommandsWithShortcuts().find((command) => command.id === "cloud.search" && command.shortcut);
|
|
556
|
+
if (command) void importOnDemand(() => import("./commands")).then((commands) => commands?.runContextAwareCommand(command));
|
|
557
|
+
}
|
|
197
558
|
};
|
|
198
559
|
const click = (event: MouseEvent) => {
|
|
199
560
|
if (!(event.target instanceof Element)) return;
|
|
200
|
-
|
|
561
|
+
const answer = event.target.closest<HTMLElement>("[data-cloud-panel-consent-answer]");
|
|
562
|
+
if (answer && isOpen()) void saveConsent({ provider: provider.id, allowed: answer.dataset.cloudPanelConsentAnswer === "allow" });
|
|
563
|
+
else if (event.target.closest("[data-cloud-panel-close]")) close();
|
|
201
564
|
else if (event.target.closest("[data-cloud-panel-retry]") && isOpen()) {
|
|
202
565
|
load(true);
|
|
203
566
|
frame?.focus();
|
|
@@ -249,7 +612,11 @@ export function installPanelSlot(): () => void {
|
|
|
249
612
|
}
|
|
250
613
|
};
|
|
251
614
|
const pageShow = (event: PageTransitionEvent) => {
|
|
252
|
-
if (event.persisted)
|
|
615
|
+
if (!event.persisted) return;
|
|
616
|
+
closeSheet();
|
|
617
|
+
// A restored page may hold answers changed elsewhere meanwhile: stop sharing and read them again.
|
|
618
|
+
if (!panel.providers.some((item) => item.consent)) return;
|
|
619
|
+
void revalidate();
|
|
253
620
|
};
|
|
254
621
|
const entryChange = (event: NavigationCurrentEntryChangeEvent) => {
|
|
255
622
|
if (event.navigationType === "push") closeSheet();
|
|
@@ -313,12 +680,18 @@ export function installPanelSlot(): () => void {
|
|
|
313
680
|
})
|
|
314
681
|
: () => {};
|
|
315
682
|
let disposeResize = attachResize();
|
|
683
|
+
collectContext();
|
|
684
|
+
window.addEventListener(PAGE_CONTEXT_CHANGED, collectContext);
|
|
685
|
+
window.addEventListener(COMMANDS_CHANGED, commandsChanged);
|
|
686
|
+
window.addEventListener("cloud.panel.consent", consentRequest);
|
|
316
687
|
window.addEventListener("message", message);
|
|
317
688
|
window.addEventListener("keydown", key);
|
|
318
689
|
slot.addEventListener("click", click);
|
|
319
690
|
phoneChange.addEventListener("change", modeChange);
|
|
320
691
|
column.addEventListener("change", modeChange);
|
|
321
692
|
window.addEventListener("pageshow", pageShow);
|
|
693
|
+
// Restored from the back/forward cache before this lazy module arrived: the server's answers may be outdated.
|
|
694
|
+
if (document.documentElement.hasAttribute("data-cloud-page-restored")) void revalidate();
|
|
322
695
|
window.addEventListener("popstate", closeSheet);
|
|
323
696
|
window.navigation?.addEventListener("currententrychange", entryChange);
|
|
324
697
|
window.addEventListener("resize", seedWidth);
|
|
@@ -331,13 +704,42 @@ export function installPanelSlot(): () => void {
|
|
|
331
704
|
} else if (isOpen()) load();
|
|
332
705
|
else unload();
|
|
333
706
|
update();
|
|
334
|
-
|
|
707
|
+
// Mounted only now, after update() wrote the state it starts from; a click before this has nothing to change.
|
|
708
|
+
let disposeMenu = () => {};
|
|
709
|
+
if (menuHost) {
|
|
710
|
+
menuHost.replaceChildren();
|
|
711
|
+
const initial: PanelState = {
|
|
712
|
+
provider: provider.id,
|
|
713
|
+
appName: provider.appName,
|
|
714
|
+
consent: provider.consent,
|
|
715
|
+
allowed: allowed(),
|
|
716
|
+
saving: saving.has(provider.id),
|
|
717
|
+
open: isOpen(),
|
|
718
|
+
};
|
|
719
|
+
disposeMenu = render(
|
|
720
|
+
() =>
|
|
721
|
+
createComponent(LocaleProvider, {
|
|
722
|
+
locale: lang,
|
|
723
|
+
get children() {
|
|
724
|
+
return createComponent(PanelMenu, { initial, locale: lang });
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
menuHost,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
335
730
|
const dispose = () => {
|
|
336
731
|
if (disposed) return;
|
|
337
732
|
disposed = true;
|
|
733
|
+
disposeMenu();
|
|
734
|
+
requests.abort();
|
|
735
|
+
++loadVersion;
|
|
736
|
+
clearContext();
|
|
338
737
|
clearTimer();
|
|
339
738
|
disposeResize();
|
|
340
739
|
removeFallbackFocus();
|
|
740
|
+
window.removeEventListener(PAGE_CONTEXT_CHANGED, collectContext);
|
|
741
|
+
window.removeEventListener(COMMANDS_CHANGED, commandsChanged);
|
|
742
|
+
window.removeEventListener("cloud.panel.consent", consentRequest);
|
|
341
743
|
window.removeEventListener("message", message);
|
|
342
744
|
window.removeEventListener("keydown", key);
|
|
343
745
|
slot.removeEventListener("click", click);
|
package/src/browser/panel.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import type { PanelPageContext } from "../contracts/panel-context";
|
|
2
|
+
import {
|
|
3
|
+
PAGE_CONTEXT_CHANGED,
|
|
4
|
+
PAGE_CONTEXT_COLLECT,
|
|
5
|
+
PANEL_CONTEXT_CHANGED,
|
|
6
|
+
type PageContextCollection,
|
|
7
|
+
type PageContextSource,
|
|
8
|
+
readProviderContext,
|
|
9
|
+
} from "./panel-context-bridge";
|
|
10
|
+
|
|
1
11
|
export type PanelRequest = { action: "open"; provider: string; search?: string; accepted: boolean } | { action: "close" | "toggle" };
|
|
2
12
|
|
|
3
13
|
declare global {
|
|
@@ -26,3 +36,30 @@ export function closePanel(): void {
|
|
|
26
36
|
}
|
|
27
37
|
window.dispatchEvent(new CustomEvent<PanelRequest>("cloud.panel.request", { detail: { action: "close" } }));
|
|
28
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Publish in a reactive effect; dispose the previous call when that source changes or unmounts. */
|
|
41
|
+
export function publishPageContext(context: PageContextSource): () => void {
|
|
42
|
+
const source = { ...context, ...(context.scope ? { scope: { ...context.scope } } : {}) };
|
|
43
|
+
const collect = (event: Event) => (event as CustomEvent<PageContextCollection>).detail.sources.push(source);
|
|
44
|
+
window.addEventListener(PAGE_CONTEXT_COLLECT, collect);
|
|
45
|
+
window.dispatchEvent(new Event(PAGE_CONTEXT_CHANGED));
|
|
46
|
+
let active = true;
|
|
47
|
+
return () => {
|
|
48
|
+
if (!active) return;
|
|
49
|
+
active = false;
|
|
50
|
+
window.removeEventListener(PAGE_CONTEXT_COLLECT, collect);
|
|
51
|
+
window.dispatchEvent(new Event(PAGE_CONTEXT_CHANGED));
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The validated URL snapshot, then the most recent accepted host message. */
|
|
56
|
+
export function getContext(): PanelPageContext | null {
|
|
57
|
+
return readProviderContext();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Called on each accepted change, including null on close or withdrawal. */
|
|
61
|
+
export function onContextChange(cb: (context: PanelPageContext | null) => void): () => void {
|
|
62
|
+
const changed = () => cb(getContext());
|
|
63
|
+
window.addEventListener(PANEL_CONTEXT_CHANGED, changed);
|
|
64
|
+
return () => window.removeEventListener(PANEL_CONTEXT_CHANGED, changed);
|
|
65
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import type { SearchItem } from "../api/search/schemas";
|
|
3
2
|
import { listCapabilityCatalog } from "../capabilities/client";
|
|
4
3
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
5
4
|
import type { ContextAwareCommand } from "./command-bridge";
|
|
6
5
|
|
|
6
|
+
import { discoverableCommandInput } from "./discoverable-command";
|
|
7
|
+
import { navigationApps } from "./navigation-apps";
|
|
8
|
+
|
|
7
9
|
export type PaletteCommand = ContextAwareCommand & { appName?: string; keywords?: readonly string[]; context?: boolean };
|
|
8
10
|
export const loadSearchCommands = async (locale: string, signal: AbortSignal): Promise<PaletteCommand[]> => {
|
|
9
11
|
const commands: PaletteCommand[] = [];
|
|
12
|
+
const { appIds } = navigationApps();
|
|
10
13
|
let cursor: string | undefined;
|
|
11
14
|
do {
|
|
12
15
|
const result = await listCapabilityCatalog({ limit: 25, cursor, signal, headers: { [LOCALE_HEADER]: locale } });
|
|
@@ -14,8 +17,8 @@ export const loadSearchCommands = async (locale: string, signal: AbortSignal): P
|
|
|
14
17
|
for (const app of result.data.apps) {
|
|
15
18
|
for (const command of app.manifest.commands) {
|
|
16
19
|
// Only Commands with a valid empty invocation are globally discoverable.
|
|
17
|
-
const input =
|
|
18
|
-
if (!input
|
|
20
|
+
const input = discoverableCommandInput(command, app.appId, appIds);
|
|
21
|
+
if (!input?.success) continue;
|
|
19
22
|
const id = `${app.appId}.${command.localId}`;
|
|
20
23
|
commands.push({
|
|
21
24
|
id,
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import {
|
|
3
3
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
4
4
|
CAPABILITY_MAX_CATALOG_BYTES,
|
|
5
|
-
|
|
5
|
+
CapabilityActionReviewDataSchema,
|
|
6
6
|
CapabilityCatalogSchema,
|
|
7
7
|
capabilityResultSchema,
|
|
8
8
|
} from "../contracts/capabilities";
|
|
@@ -142,7 +142,7 @@ export const reviewCapabilityAction = async <TInput = unknown>(
|
|
|
142
142
|
body: JSON.stringify({ input: invocation.input }),
|
|
143
143
|
signal: invocation.signal,
|
|
144
144
|
});
|
|
145
|
-
return readCapabilityResponse(response,
|
|
145
|
+
return readCapabilityResponse(response, CapabilityActionReviewDataSchema);
|
|
146
146
|
} catch (cause) {
|
|
147
147
|
return unavailable(cause, undefined, clientLocale(options.headers));
|
|
148
148
|
}
|
|
@@ -180,4 +180,10 @@ export const resolveCommand = async (
|
|
|
180
180
|
}
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
+
export {
|
|
184
|
+
actionReview,
|
|
185
|
+
type CapabilityReviewForms,
|
|
186
|
+
capabilityReviewRememberScope,
|
|
187
|
+
capabilityReviewView,
|
|
188
|
+
} from "./reviews";
|
|
183
189
|
export { type CapabilityStreamVerb, transferCapabilityStream } from "./streams";
|