@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,18 +1,9 @@
1
1
  import { dates } from "@k2b/stdlib";
2
- import {
3
- Button,
4
- ButtonLink,
5
- type DropdownItem,
6
- dialogCore,
7
- MarkdownView,
8
- PanelDialog,
9
- Placeholder,
10
- panelDialogOptions,
11
- prompts,
12
- SplitButton,
13
- useLocale,
14
- } from "@k2b/ui";
2
+ import { Button, dialogCore, PanelDialog, Placeholder, panelDialogOptions, prompts, useLocale } from "@k2b/ui";
15
3
  import { createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
4
+ import type { ActionCard } from "../ai/chat/action-card";
5
+ import { actionMessages } from "../ai/chat/action-messages";
6
+ import { ActionQuestion, ActionReceipt } from "../ai/chat/action-question";
16
7
  import { refreshAppBadges } from "../browser/app-badges";
17
8
  import { coreClient } from "../clients/core";
18
9
  import {
@@ -23,13 +14,11 @@ import {
23
14
  ApprovalPageSchema,
24
15
  type PendingApproval,
25
16
  } from "../contracts/approvals";
26
- import type { CapabilityActionReview } from "../contracts/capabilities";
17
+ import { approvalCard } from "./approval-card";
27
18
  import { approvalMessages } from "./messages";
28
19
 
29
20
  /** How a decided request ends in this list. It stays in place as a receipt until the next load. */
30
21
  type Outcome = "approved" | "rejected" | "elsewhere" | "gone";
31
- /** Approved and rejected are this person's decision; the other two mean the click changed nothing. */
32
- const decidedHere = (outcome: Outcome) => outcome === "approved" || outcome === "rejected";
33
22
  /** One icon per outcome, so a rejection or a decision made elsewhere never looks like a success. */
34
23
  const outcomeIcon = (outcome: Outcome, band = false) =>
35
24
  outcome === "approved"
@@ -41,8 +30,9 @@ const outcomeIcon = (outcome: Outcome, band = false) =>
41
30
  ? "ti ti-circle-x"
42
31
  : "ti ti-x"
43
32
  : "ti ti-info-circle";
44
- type ReviewDetail = NonNullable<CapabilityActionReview["details"]>[number];
45
33
 
34
+ /** Keep SSR and the first client render identical: the page has no reader zone of its own yet. */
35
+ const READER_TIME_ZONE = "UTC";
46
36
  export type ApprovalsProps = {
47
37
  /** List the requests whose effect belongs to this app or that this app asked for. Without, every app's, grouped. */
48
38
  app?: string;
@@ -51,8 +41,8 @@ export type ApprovalsProps = {
51
41
  /** The first page for the same query, loaded during SSR with `loadPendingApprovals`; without, the browser loads it. */
52
42
  initial?: ApprovalPage;
53
43
  /**
54
- * `list` shows the requests with their review, a count, and an empty state. `band` shows each request as one compact
55
- * row above the resource it would change, and nothing when none waits.
44
+ * `list` shows each request as the same question card as the chat, with a count and an empty state. `band` shows
45
+ * each request as one compact row above the resource it would change, and nothing when none waits.
56
46
  */
57
47
  layout?: "list" | "band";
58
48
  };
@@ -65,80 +55,15 @@ const useMessages = () => {
65
55
  return () => approvalMessages.resolve([locale()]).t;
66
56
  };
67
57
 
68
- const lines = (item: PendingApproval) =>
69
- item.sentence
70
- .split("\n")
71
- .map((line) => line.trim())
72
- .filter(Boolean);
73
-
74
- /** The first line names the app and the Action; the app already shows as the row's icon and origin. */
75
- const approvalTitle = (item: PendingApproval): string => {
76
- const first = lines(item)[0] ?? item.action;
77
- const prefix = `${item.app.name}: `;
78
- return first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
79
- };
80
-
81
- /** Without a review, the sentence's later `Label: value` lines are what the person approves. */
82
- const sentenceFields = (item: PendingApproval) =>
83
- lines(item)
84
- .slice(1)
85
- .map((line) => {
86
- const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
87
- return match ? { label: match[1], value: match[2] ?? "" } : { label: null, value: line };
88
- });
89
-
90
- const detailText = (detail: ReviewDetail, locale: string): string => {
91
- if (detail.format === "date")
92
- return /^\d{4}-\d{2}-\d{2}$/.test(detail.value)
93
- ? dates.formatDate(`${detail.value}T12:00:00.000Z`, { locale, timeZone: "UTC" })
94
- : dates.formatDate(detail.value, { locale });
95
- if (detail.format === "date-time") return dates.formatDateTime(detail.value, { locale });
96
- return detail.value;
97
- };
98
-
99
- const inlineDetails = (item: PendingApproval) => (item.review?.details ?? []).filter((detail) => (detail.display ?? "inline") === "inline");
100
- const blockDetails = (item: PendingApproval) => (item.review?.details ?? []).filter((detail) => detail.display === "block");
101
-
102
- function Fields(props: { item: PendingApproval }) {
103
- const locale = useLocale();
104
- const fields = () =>
105
- props.item.review
106
- ? inlineDetails(props.item).map((detail) => ({ label: detail.label, value: detailText(detail, locale()) }))
107
- : sentenceFields(props.item).filter((field): field is { label: string; value: string } => field.label !== null);
108
- return (
109
- <Show when={fields().length > 0}>
110
- <dl class="cloud-approval__fields">
111
- <For each={fields()}>
112
- {(field) => (
113
- <>
114
- <dt>{field.label}</dt>
115
- <dd>{field.value}</dd>
116
- </>
117
- )}
118
- </For>
119
- </dl>
120
- </Show>
121
- );
122
- }
123
-
124
- /** The review's message, or the sentence's plain lines when the Action has no review. */
125
- function Description(props: { item: PendingApproval }) {
126
- const text = () => {
127
- const message = props.item.review?.message.trim();
128
- if (message) return message === approvalTitle(props.item) ? null : message;
129
- const plain = sentenceFields(props.item)
130
- .filter((field) => field.label === null)
131
- .map((field) => field.value);
132
- return plain.length ? plain.join("\n") : null;
133
- };
134
- return (
135
- <Show when={text()}>
136
- <p class="cloud-approval__text">{text()}</p>
137
- </Show>
138
- );
139
- }
58
+ const cardInput = (item: PendingApproval) => ({
59
+ sentence: item.sentence,
60
+ action: item.action,
61
+ appName: item.app.name,
62
+ appIcon: item.app.icon,
63
+ review: item.review,
64
+ });
140
65
 
141
- /** Origin of the request: owning app, the chat it waits in, and when Cloud asked. The chat title is content, not frame. */
66
+ /** Origin of the request: owning app, the chat it waits in, when Cloud asked, and when it expires. */
142
67
  function Origin(props: { item: PendingApproval; withApp?: boolean }) {
143
68
  const t = useMessages();
144
69
  const locale = useLocale();
@@ -153,168 +78,131 @@ function Origin(props: { item: PendingApproval; withApp?: boolean }) {
153
78
  </a>
154
79
  <span aria-hidden="true"> · </span>
155
80
  <time dateTime={props.item.createdAt}>{dates.formatDateTimeRelative(props.item.createdAt, { locale: locale() })}</time>
81
+ <span aria-hidden="true"> · </span>
82
+ <span>{t().expiresIn({ time: dates.formatTimeSpan(props.item.expiresAt, { locale: locale() }) })}</span>
156
83
  </p>
157
84
  );
158
85
  }
159
86
 
160
- /** "Asks every time · Expires in 23 hours", or the error of the last decision in the same place. */
161
- function Note(props: { item: PendingApproval; outcome?: Outcome; failed?: boolean }) {
162
- const t = useMessages();
163
- const locale = useLocale();
164
- const text = () => {
165
- if (props.outcome) return decidedHere(props.outcome) ? t().decidedHere : t().notApplied;
166
- if (props.failed) return t().decisionFailed;
167
- const expires = t().expiresIn({ time: dates.formatTimeSpan(props.item.expiresAt, { locale: locale() }) });
168
- return props.item.assurance === "confirm" ? `${t().asksEveryTime} · ${expires}` : expires;
169
- };
170
- const icon = () =>
171
- props.outcome
172
- ? decidedHere(props.outcome)
173
- ? "ti ti-clock"
174
- : "ti ti-info-circle"
175
- : props.failed
176
- ? "ti ti-alert-circle"
177
- : props.item.assurance === "confirm"
178
- ? "ti ti-lock"
179
- : "ti ti-clock";
180
- return (
181
- <span
182
- class="cloud-approval__note"
183
- data-tone={props.failed && !props.outcome ? "danger" : undefined}
184
- role={props.failed ? "alert" : undefined}
185
- >
186
- <i class={icon()} aria-hidden="true" />
187
- <span title={text()}>{text()}</span>
188
- </span>
189
- );
87
+ /** The outcome lines a request on this page can reach; the chat it came from shows how the Action itself ends. */
88
+ const outcomeLines = (card: ActionCard, words: ReturnType<typeof approvalMessages.resolve>["t"], reopen: () => void) => ({
89
+ sending: { tone: "running" as const, text: [{ kind: "text" as const, text: words.sending }] },
90
+ approved: { tone: "done" as const, text: [{ kind: "text" as const, text: words.approvedHere }], detail: words.continuesInChat },
91
+ rejected: { tone: "off" as const, text: card.forms.rejected, detail: words.declinedHere },
92
+ elsewhere: { tone: "off" as const, icon: "ti ti-info-circle", text: [{ kind: "text" as const, text: words.decidedElsewhere }] },
93
+ gone: { tone: "off" as const, icon: "ti ti-clock-x", text: [{ kind: "text" as const, text: words.gone }] },
94
+ failed: {
95
+ tone: "warning" as const,
96
+ icon: "ti ti-alert-triangle",
97
+ text: [{ kind: "text" as const, text: words.notApplied }],
98
+ detail: words.decisionFailed,
99
+ action: { label: words.answerAgain, onSelect: reopen },
100
+ },
101
+ });
102
+
103
+ /** Answers that arm late, so a click meant for what was there before cannot approve a request that just appeared. */
104
+ function Armed(props: { children: JSX.Element }) {
105
+ const [armed, setArmed] = createSignal(false);
106
+ onMount(() => {
107
+ const timer = setTimeout(() => setArmed(true), ARM_DELAY_MS);
108
+ onCleanup(() => clearTimeout(timer));
109
+ });
110
+ return <div inert={armed() ? undefined : true}>{props.children}</div>;
190
111
  }
191
112
 
192
- function Receipt(props: { outcome: Outcome }) {
113
+ /** One request as the chat's question card. Its answers arm late, so a click meant for the page cannot approve it. */
114
+ function ApprovalCard(props: {
115
+ item: PendingApproval;
116
+ outcome?: Outcome;
117
+ busy: boolean;
118
+ failed: boolean;
119
+ decide: (input: ApprovalDecisionInput) => void;
120
+ reopen: () => void;
121
+ }) {
193
122
  const t = useMessages();
194
- const label = () => ({ approved: t().approved, rejected: t().rejected, elsewhere: t().decidedElsewhere, gone: t().gone })[props.outcome];
123
+ const locale = useLocale();
124
+ const card = createMemo(() => approvalCard(cardInput(props.item), locale(), READER_TIME_ZONE));
125
+ const current = () => props.outcome ?? (props.failed ? "failed" : "sending");
126
+ const state = () => (props.outcome || props.busy || props.failed ? "decided" : "open");
127
+ // `confirm` asks every time, whatever the stored options say.
128
+ const remember = () =>
129
+ props.item.remember.chat && props.item.assurance === "standard"
130
+ ? {
131
+ label: props.item.remember.website ? actionMessages(card().locale).rememberWebsite : actionMessages(card().locale).remember,
132
+ onSelect: () => props.decide({ approved: true, remember: "chat" }),
133
+ }
134
+ : undefined;
195
135
  return (
196
- <span class="cloud-approval__receipt" data-outcome={props.outcome}>
197
- <i class={outcomeIcon(props.outcome)} aria-hidden="true" />
198
- {label()}
199
- </span>
136
+ <article class="cloud-approval" data-decided={props.outcome} aria-label={t().approvalFor({ title: card().questionText })}>
137
+ <Origin item={props.item} />
138
+ <Armed>
139
+ <ActionQuestion
140
+ card={card()}
141
+ state={state()}
142
+ timeZone={READER_TIME_ZONE}
143
+ remember={remember()}
144
+ onConfirm={() => props.decide({ approved: true })}
145
+ onDecline={() => props.decide({ approved: false })}
146
+ // The outcome lines join the card's sentence, so they speak the stored review's language.
147
+ outcome={
148
+ <ActionReceipt
149
+ card={card()}
150
+ lines={outcomeLines(card(), approvalMessages.resolve([card().locale]).t, props.reopen)}
151
+ current={current()}
152
+ />
153
+ }
154
+ />
155
+ </Armed>
156
+ </article>
200
157
  );
201
158
  }
202
159
 
203
- /** Reject, then approve; remember options only where the request offers them, never for `confirm`. */
204
- function Decision(props: { item: PendingApproval; busy: boolean; details?: () => void; decide: (input: ApprovalDecisionInput) => void }) {
160
+ /** Reject, then approve, for the compact band; the full card opens in a panel. */
161
+ function BandDecision(props: {
162
+ item: PendingApproval;
163
+ busy: boolean;
164
+ details: () => void;
165
+ decide: (input: ApprovalDecisionInput) => void;
166
+ }) {
205
167
  const t = useMessages();
206
168
  const [armed, setArmed] = createSignal(false);
207
169
  onMount(() => {
208
170
  const timer = setTimeout(() => setArmed(true), ARM_DELAY_MS);
209
171
  onCleanup(() => clearTimeout(timer));
210
172
  });
211
- const rememberItems = (): DropdownItem[] => [
212
- ...(props.item.remember.chat
213
- ? [
214
- {
215
- label: props.item.remember.website ? t().allowWebsiteForChat : t().approveForChat,
216
- icon: props.item.remember.website ? "ti ti-world-check" : "ti ti-message-check",
217
- action: () => props.decide({ approved: true, remember: "chat" }),
218
- },
219
- ]
220
- : []),
221
- ...(props.item.remember.always
222
- ? [{ label: t().alwaysApprove, icon: "ti ti-shield-check", action: () => props.decide({ approved: true, remember: "always" }) }]
223
- : []),
224
- ];
225
173
  return (
226
174
  // Inert, not disabled, while arming: the buttons look the same from the first frame, so nothing flickers.
227
175
  <div class="cloud-approval__actions" inert={armed() ? undefined : true}>
228
- <Show when={props.details}>
229
- {(open) => (
230
- <Button size="sm" variant="ghost" onClick={() => open()()}>
231
- {t().details}
232
- </Button>
233
- )}
234
- </Show>
176
+ <Button size="sm" variant="ghost" onClick={() => props.details()}>
177
+ {t().details}
178
+ </Button>
235
179
  <Button size="sm" variant="ghost" disabled={props.busy} onClick={() => props.decide({ approved: false })}>
236
180
  {t().reject}
237
181
  </Button>
238
- <Show
239
- when={props.item.assurance === "standard" && rememberItems().length > 0}
240
- fallback={
241
- <Button size="sm" variant="primary" loading={props.busy} onClick={() => props.decide({ approved: true })}>
242
- {t().approve}
243
- </Button>
244
- }
245
- >
246
- <SplitButton
247
- size="sm"
248
- variant="primary"
249
- loading={props.busy}
250
- menuLabel={t().moreOptions}
251
- menuPosition="bottom-right"
252
- items={rememberItems()}
253
- onClick={() => props.decide({ approved: true })}
254
- >
255
- {t().approve}
256
- </SplitButton>
257
- </Show>
182
+ <Button size="sm" variant="primary" loading={props.busy} onClick={() => props.decide({ approved: true })}>
183
+ {t().approve}
184
+ </Button>
258
185
  </div>
259
186
  );
260
187
  }
261
188
 
262
- function ReviewLinks(props: { item: PendingApproval }) {
189
+ function Receipt(props: { outcome: Outcome }) {
263
190
  const t = useMessages();
264
- const title = (rel: "open" | "edit" | "status" | "preview" | "download") =>
265
- ({
266
- open: t().openIn({ app: props.item.app.name }),
267
- edit: t().editIn({ app: props.item.app.name }),
268
- status: t().statusIn({ app: props.item.app.name }),
269
- preview: t().preview,
270
- download: t().download,
271
- })[rel];
272
- return (
273
- <Show when={(props.item.review?.links ?? []).length > 0}>
274
- <nav class="cloud-approval__links" aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
275
- <For each={props.item.review?.links ?? []}>
276
- {(link) => (
277
- <ButtonLink href={link.href} size="sm" variant="ghost">
278
- <i class="ti ti-external-link" aria-hidden="true" />
279
- {link.title ?? title(link.rel)}
280
- </ButtonLink>
281
- )}
282
- </For>
283
- </nav>
284
- </Show>
285
- );
286
- }
287
-
288
- function BlockDetails(props: { item: PendingApproval; clamp?: boolean }) {
289
- const locale = useLocale();
191
+ const label = () => ({ approved: t().approved, rejected: t().rejected, elsewhere: t().decidedElsewhere, gone: t().gone })[props.outcome];
290
192
  return (
291
- <For each={props.clamp ? blockDetails(props.item).slice(0, 1) : blockDetails(props.item)}>
292
- {(detail) => (
293
- <section class="cloud-approval__block" aria-label={detail.label}>
294
- <Show when={!props.clamp}>
295
- <h4 class="cloud-approval__label">{detail.label}</h4>
296
- </Show>
297
- <Show
298
- when={detail.format === "markdown" && !props.clamp}
299
- fallback={
300
- <p class="cloud-approval__quote" data-clamp={props.clamp ? "true" : undefined}>
301
- <span>{detailText(detail, locale())}</span>
302
- </p>
303
- }
304
- >
305
- <MarkdownView class="cloud-approval__markdown" markdown={detail.value} headingScale="compact" allowImages={false} />
306
- </Show>
307
- </section>
308
- )}
309
- </For>
193
+ <span class="cloud-approval__receipt" data-outcome={props.outcome}>
194
+ <i class={outcomeIcon(props.outcome)} aria-hidden="true" />
195
+ {label()}
196
+ </span>
310
197
  );
311
198
  }
312
199
 
313
- /** Opens the full request in a panel; resolves with the decision made there, if any. */
200
+ /** Opens the full request in a panel as the chat's card; resolves with the decision made there, if any. */
314
201
  const openDetails = (item: PendingApproval, locale: string) =>
315
202
  dialogCore.open<ApprovalDecisionInput>(
316
203
  (close) => {
317
204
  const t = () => approvalMessages.resolve([locale]).t;
205
+ const card = approvalCard(cardInput(item), locale, READER_TIME_ZONE);
318
206
  return (
319
207
  <PanelDialog>
320
208
  <PanelDialog.Header
@@ -326,29 +214,40 @@ const openDetails = (item: PendingApproval, locale: string) =>
326
214
  />
327
215
  <PanelDialog.Body>
328
216
  <div class="cloud-approval__sheet">
329
- <h3 class="cloud-approval__sheet-title">{approvalTitle(item)}</h3>
330
- <Description item={item} />
331
- <Fields item={item} />
332
- <BlockDetails item={item} />
333
- <ReviewLinks item={item} />
217
+ <Armed>
218
+ <ActionQuestion
219
+ card={card}
220
+ state="open"
221
+ timeZone={READER_TIME_ZONE}
222
+ remember={
223
+ item.remember.chat && item.assurance === "standard"
224
+ ? {
225
+ label: item.remember.website ? actionMessages(card.locale).rememberWebsite : actionMessages(card.locale).remember,
226
+ onSelect: () => close({ approved: true, remember: "chat" }),
227
+ }
228
+ : undefined
229
+ }
230
+ onConfirm={() => close({ approved: true })}
231
+ onDecline={() => close({ approved: false })}
232
+ outcome={null}
233
+ />
234
+ </Armed>
334
235
  <p class="cloud-approval__callout">
335
236
  <i class="ti ti-info-circle" aria-hidden="true" />
336
237
  {t().exactlyThis}
337
238
  </p>
338
239
  </div>
339
240
  </PanelDialog.Body>
340
- <PanelDialog.Footer>
341
- <div class="cloud-approval__sheet-foot">
342
- <div class="cloud-approval__foot">
343
- <Note item={item} />
344
- <Decision item={item} busy={false} decide={(input) => close(input)} />
345
- </div>
346
- </div>
347
- </PanelDialog.Footer>
348
241
  </PanelDialog>
349
242
  );
350
243
  },
351
- { ...panelDialogOptions, history: true, ariaLabel: approvalMessages.resolve([locale]).t.approvalFor({ title: approvalTitle(item) }) },
244
+ {
245
+ ...panelDialogOptions,
246
+ history: true,
247
+ ariaLabel: approvalMessages
248
+ .resolve([locale])
249
+ .t.approvalFor({ title: approvalCard(cardInput(item), locale, READER_TIME_ZONE).questionText }),
250
+ },
352
251
  );
353
252
 
354
253
  function Row(props: {
@@ -358,53 +257,57 @@ function Row(props: {
358
257
  busy: boolean;
359
258
  failed: boolean;
360
259
  decide: (input: ApprovalDecisionInput) => void;
260
+ reopen: () => void;
361
261
  }) {
362
262
  const t = useMessages();
363
263
  const locale = useLocale();
264
+ // The band words the request as the card does: the plain question and Cloud's reason line.
265
+ const card = createMemo(() => approvalCard(cardInput(props.item), locale(), READER_TIME_ZONE));
364
266
  const details = async () => {
365
267
  const input = await openDetails(props.item, locale());
366
268
  if (input) props.decide(input);
367
269
  };
368
- const foot = () => (
369
- <>
370
- <Note item={props.item} outcome={props.outcome} failed={props.failed} />
371
- <Show when={props.outcome} fallback={<Decision item={props.item} busy={props.busy} details={details} decide={props.decide} />}>
372
- {(outcome) => (
373
- <div class="cloud-approval__actions">
374
- <Receipt outcome={outcome()} />
375
- </div>
376
- )}
377
- </Show>
378
- </>
379
- );
380
270
  return (
381
271
  <Show
382
272
  when={props.layout === "band"}
383
273
  fallback={
384
- <article class="cloud-approval" data-decided={props.outcome} aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
385
- <span class="cloud-approval__icon" aria-hidden="true">
386
- <i class={props.outcome ? outcomeIcon(props.outcome) : (props.item.app.icon ?? "ti ti-shield-check")} />
387
- </span>
388
- <div class="cloud-approval__main">
389
- <div class="cloud-approval__content">
390
- <h3 class="cloud-approval__title">{approvalTitle(props.item)}</h3>
391
- <Origin item={props.item} />
392
- <Description item={props.item} />
393
- <Fields item={props.item} />
394
- <BlockDetails item={props.item} clamp />
395
- </div>
396
- <div class="cloud-approval__foot">{foot()}</div>
397
- </div>
398
- </article>
274
+ <ApprovalCard
275
+ item={props.item}
276
+ outcome={props.outcome}
277
+ busy={props.busy}
278
+ failed={props.failed}
279
+ decide={props.decide}
280
+ reopen={props.reopen}
281
+ />
399
282
  }
400
283
  >
401
- <article class="cloud-approval-band" data-decided={props.outcome} aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
284
+ <article class="cloud-approval-band" data-decided={props.outcome} aria-label={t().approvalFor({ title: card().questionText })}>
402
285
  <i class={props.outcome ? outcomeIcon(props.outcome, true) : "ti ti-alert-triangle"} aria-hidden="true" />
403
286
  <div class="cloud-approval-band__text">
404
- <strong>{approvalTitle(props.item)}</strong>
287
+ <strong lang={card().locale}>{card().questionText}</strong>
288
+ <span class="cloud-approval-band__reason" lang={card().locale}>
289
+ {card().reason.text}
290
+ </span>
405
291
  <Origin item={props.item} withApp={false} />
406
292
  </div>
407
- <div class="cloud-approval__foot">{foot()}</div>
293
+ <div class="cloud-approval__foot">
294
+ <Show when={props.failed && !props.outcome}>
295
+ <span class="cloud-approval__note" data-tone="danger" role="alert">
296
+ <i class="ti ti-alert-circle" aria-hidden="true" />
297
+ <span>{t().decisionFailed}</span>
298
+ </span>
299
+ </Show>
300
+ <Show
301
+ when={props.outcome}
302
+ fallback={<BandDecision item={props.item} busy={props.busy} details={() => void details()} decide={props.decide} />}
303
+ >
304
+ {(outcome) => (
305
+ <div class="cloud-approval__actions">
306
+ <Receipt outcome={outcome()} />
307
+ </div>
308
+ )}
309
+ </Show>
310
+ </div>
408
311
  </article>
409
312
  </Show>
410
313
  );
@@ -528,6 +431,7 @@ export default function Approvals(props: ApprovalsProps) {
528
431
  busy={busy().has(item.id)}
529
432
  failed={failed().has(item.id)}
530
433
  decide={(input) => void decide(item, input)}
434
+ reopen={() => setFailed((current) => toggle(current, item.id, false))}
531
435
  />
532
436
  )}
533
437
  </For>
@@ -0,0 +1,68 @@
1
+ import { capabilityQuestionText, capabilityUnknownText } from "../_internal/capability-sentences";
2
+ import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "../ai/action-review";
3
+ import { type ActionCard, legacyActionCard, reviewActionCard } from "../ai/chat/action-card";
4
+ import { aiChatMessages } from "../ai/chat/messages";
5
+ import type { CapabilityActionReviewData } from "../contracts/capabilities";
6
+
7
+ export type ApprovalCardInput = {
8
+ sentence: string;
9
+ action: string;
10
+ appName: string;
11
+ appIcon?: string | null;
12
+ review: CapabilityActionReviewData | AiStoredActionReview | null;
13
+ };
14
+
15
+ const lines = (item: ApprovalCardInput) =>
16
+ item.sentence
17
+ .split("\n")
18
+ .map((line) => line.trim())
19
+ .filter(Boolean);
20
+
21
+ /** The first line names the app and the Action; the app already shows as the row's icon and origin. */
22
+ const approvalTitle = (item: ApprovalCardInput): string => {
23
+ const first = lines(item)[0] ?? item.action;
24
+ const prefix = `${item.appName}: `;
25
+ return first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
26
+ };
27
+
28
+ /** Without a review, the sentence's later `Label: value` lines are what the person approves. */
29
+ const sentenceRows = (item: ApprovalCardInput) =>
30
+ lines(item)
31
+ .slice(1)
32
+ .map((line) => {
33
+ const match = /^([^:]{1,40}):\s+(\S.*)$/.exec(line);
34
+ return match ? { label: match[1]!, value: match[2] ?? "" } : { label: "", value: line };
35
+ });
36
+
37
+ /**
38
+ * The same card as the chat: a new review gives the app's sentence, reason, and preview; an old review its literal
39
+ * message and rows; a request without a review its sentence. Times show in the reader's zone (UTC until the page
40
+ * passes one, the same on the server and in the first browser render).
41
+ */
42
+ export const approvalCard = (item: ApprovalCardInput, locale: string, timeZone: string): ActionCard => {
43
+ const app = { name: item.appName, icon: item.appIcon ?? "ti ti-shield-check" };
44
+ const context = { locale, timeZone, app };
45
+ const review = item.review ? ("legacy" in item.review ? item.review : storedActionReview(item.review)) : null;
46
+ if (review && !isLegacyActionReview(review)) return reviewActionCard(review, context);
47
+ const title = approvalTitle(item);
48
+ const t = aiChatMessages(locale);
49
+ return legacyActionCard(
50
+ {
51
+ question: review?.legacy.question ?? capabilityQuestionText(title, locale),
52
+ rows: review?.legacy.rows ?? sentenceRows(item),
53
+ ...(review?.legacy.markdown ? { markdown: review.legacy.markdown } : {}),
54
+ ...(review?.legacy.links ? { links: review.legacy.links } : {}),
55
+ subject: title,
56
+ title,
57
+ outcome: {
58
+ running: t.receiptRunning({ title }),
59
+ done: t.receiptDone({ title }),
60
+ rejected: t.receiptRejected({ title }),
61
+ notRun: t.receiptNotRun({ title, stopped: false }),
62
+ unknown: capabilityUnknownText(title, locale),
63
+ unknownStill: capabilityUnknownText(title, locale, true),
64
+ },
65
+ },
66
+ context,
67
+ );
68
+ };