@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.
Files changed (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. 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 { isPanelCloseMessage, isPanelReadyMessage } from "./panel-messages";
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 t = platformMessages.resolve([document.documentElement.lang || "en"]).t;
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
- const load = (replace = false) => {
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?.src === url.href) return;
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
- load(search !== undefined && shown !== undefined && shown !== "about:blank" && shown !== target().href);
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?.focus();
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
- if (event.target.closest("[data-cloud-panel-close]")) close();
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) closeSheet();
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
- let disposed = false;
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);
@@ -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 = z.fromJSONSchema(structuredClone(command.inputSchema)).safeParse({});
18
- if (!input.success) continue;
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
- CapabilityActionReviewSchema,
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, CapabilityActionReviewSchema);
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";