@k2b/cloud 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -1,18 +1,12 @@
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 { capabilityQuestionText, capabilityUnknownText } from "../_internal/capability-sentences";
5
+ import { isLegacyActionReview, storedActionReview } from "../ai/action-review";
6
+ import { type ActionCard, legacyActionCard, reviewActionCard } from "../ai/chat/action-card";
7
+ import { actionMessages } from "../ai/chat/action-messages";
8
+ import { ActionQuestion, ActionReceipt } from "../ai/chat/action-question";
9
+ import { aiChatMessages } from "../ai/chat/messages";
16
10
  import { refreshAppBadges } from "../browser/app-badges";
17
11
  import { coreClient } from "../clients/core";
18
12
  import {
@@ -23,13 +17,10 @@ import {
23
17
  ApprovalPageSchema,
24
18
  type PendingApproval,
25
19
  } from "../contracts/approvals";
26
- import type { CapabilityActionReview } from "../contracts/capabilities";
27
20
  import { approvalMessages } from "./messages";
28
21
 
29
22
  /** How a decided request ends in this list. It stays in place as a receipt until the next load. */
30
23
  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
24
  /** One icon per outcome, so a rejection or a decision made elsewhere never looks like a success. */
34
25
  const outcomeIcon = (outcome: Outcome, band = false) =>
35
26
  outcome === "approved"
@@ -41,8 +32,9 @@ const outcomeIcon = (outcome: Outcome, band = false) =>
41
32
  ? "ti ti-circle-x"
42
33
  : "ti ti-x"
43
34
  : "ti ti-info-circle";
44
- type ReviewDetail = NonNullable<CapabilityActionReview["details"]>[number];
45
35
 
36
+ /** Keep SSR and the first client render identical: the page has no reader zone of its own yet. */
37
+ const READER_TIME_ZONE = "UTC";
46
38
  export type ApprovalsProps = {
47
39
  /** List the requests whose effect belongs to this app or that this app asked for. Without, every app's, grouped. */
48
40
  app?: string;
@@ -51,8 +43,8 @@ export type ApprovalsProps = {
51
43
  /** The first page for the same query, loaded during SSR with `loadPendingApprovals`; without, the browser loads it. */
52
44
  initial?: ApprovalPage;
53
45
  /**
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.
46
+ * `list` shows each request as the same question card as the chat, with a count and an empty state. `band` shows
47
+ * each request as one compact row above the resource it would change, and nothing when none waits.
56
48
  */
57
49
  layout?: "list" | "band";
58
50
  };
@@ -79,66 +71,48 @@ const approvalTitle = (item: PendingApproval): string => {
79
71
  };
80
72
 
81
73
  /** Without a review, the sentence's later `Label: value` lines are what the person approves. */
82
- const sentenceFields = (item: PendingApproval) =>
74
+ const sentenceRows = (item: PendingApproval) =>
83
75
  lines(item)
84
76
  .slice(1)
85
77
  .map((line) => {
86
78
  const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
87
- return match ? { label: match[1], value: match[2] ?? "" } : { label: null, value: line };
79
+ return match ? { label: match[1]!, value: match[2] ?? "" } : { label: "", value: line };
88
80
  });
89
81
 
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>
82
+ /**
83
+ * The same card as the chat: a new review gives the app's sentence, reason, and preview; an old review its literal
84
+ * message and rows; a request without a review its sentence. Times show in the reader's zone (UTC until the page
85
+ * passes one, the same on the server and in the first browser render).
86
+ */
87
+ const approvalCard = (item: PendingApproval, locale: string, timeZone: string): ActionCard => {
88
+ const app = { name: item.app.name, icon: item.app.icon ?? "ti ti-shield-check" };
89
+ const context = { locale, timeZone, app };
90
+ const review = item.review ? storedActionReview(item.review) : null;
91
+ if (review && !isLegacyActionReview(review)) return reviewActionCard(review, context);
92
+ const title = approvalTitle(item);
93
+ const t = aiChatMessages(locale);
94
+ return legacyActionCard(
95
+ {
96
+ question: review?.legacy.question ?? capabilityQuestionText(title, locale),
97
+ rows: review?.legacy.rows ?? sentenceRows(item),
98
+ ...(review?.legacy.markdown ? { markdown: review.legacy.markdown } : {}),
99
+ ...(review?.legacy.links ? { links: review.legacy.links } : {}),
100
+ subject: title,
101
+ title,
102
+ outcome: {
103
+ running: t.receiptRunning({ title }),
104
+ done: t.receiptDone({ title }),
105
+ rejected: t.receiptRejected({ title }),
106
+ notRun: t.receiptNotRun({ title, stopped: false }),
107
+ unknown: capabilityUnknownText(title, locale),
108
+ unknownStill: capabilityUnknownText(title, locale, true),
109
+ },
110
+ },
111
+ context,
138
112
  );
139
- }
113
+ };
140
114
 
141
- /** Origin of the request: owning app, the chat it waits in, and when Cloud asked. The chat title is content, not frame. */
115
+ /** Origin of the request: owning app, the chat it waits in, when Cloud asked, and when it expires. */
142
116
  function Origin(props: { item: PendingApproval; withApp?: boolean }) {
143
117
  const t = useMessages();
144
118
  const locale = useLocale();
@@ -153,168 +127,131 @@ function Origin(props: { item: PendingApproval; withApp?: boolean }) {
153
127
  </a>
154
128
  <span aria-hidden="true"> · </span>
155
129
  <time dateTime={props.item.createdAt}>{dates.formatDateTimeRelative(props.item.createdAt, { locale: locale() })}</time>
130
+ <span aria-hidden="true"> · </span>
131
+ <span>{t().expiresIn({ time: dates.formatTimeSpan(props.item.expiresAt, { locale: locale() }) })}</span>
156
132
  </p>
157
133
  );
158
134
  }
159
135
 
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
- );
136
+ /** The outcome lines a request on this page can reach; the chat it came from shows how the Action itself ends. */
137
+ const outcomeLines = (card: ActionCard, words: ReturnType<typeof approvalMessages.resolve>["t"], reopen: () => void) => ({
138
+ sending: { tone: "running" as const, text: [{ kind: "text" as const, text: words.sending }] },
139
+ approved: { tone: "done" as const, text: [{ kind: "text" as const, text: words.approvedHere }], detail: words.continuesInChat },
140
+ rejected: { tone: "off" as const, text: card.forms.rejected, detail: words.declinedHere },
141
+ elsewhere: { tone: "off" as const, icon: "ti ti-info-circle", text: [{ kind: "text" as const, text: words.decidedElsewhere }] },
142
+ gone: { tone: "off" as const, icon: "ti ti-clock-x", text: [{ kind: "text" as const, text: words.gone }] },
143
+ failed: {
144
+ tone: "warning" as const,
145
+ icon: "ti ti-alert-triangle",
146
+ text: [{ kind: "text" as const, text: words.notApplied }],
147
+ detail: words.decisionFailed,
148
+ action: { label: words.answerAgain, onSelect: reopen },
149
+ },
150
+ });
151
+
152
+ /** Answers that arm late, so a click meant for what was there before cannot approve a request that just appeared. */
153
+ function Armed(props: { children: JSX.Element }) {
154
+ const [armed, setArmed] = createSignal(false);
155
+ onMount(() => {
156
+ const timer = setTimeout(() => setArmed(true), ARM_DELAY_MS);
157
+ onCleanup(() => clearTimeout(timer));
158
+ });
159
+ return <div inert={armed() ? undefined : true}>{props.children}</div>;
190
160
  }
191
161
 
192
- function Receipt(props: { outcome: Outcome }) {
162
+ /** One request as the chat's question card. Its answers arm late, so a click meant for the page cannot approve it. */
163
+ function ApprovalCard(props: {
164
+ item: PendingApproval;
165
+ outcome?: Outcome;
166
+ busy: boolean;
167
+ failed: boolean;
168
+ decide: (input: ApprovalDecisionInput) => void;
169
+ reopen: () => void;
170
+ }) {
193
171
  const t = useMessages();
194
- const label = () => ({ approved: t().approved, rejected: t().rejected, elsewhere: t().decidedElsewhere, gone: t().gone })[props.outcome];
172
+ const locale = useLocale();
173
+ const card = createMemo(() => approvalCard(props.item, locale(), READER_TIME_ZONE));
174
+ const current = () => props.outcome ?? (props.failed ? "failed" : "sending");
175
+ const state = () => (props.outcome || props.busy || props.failed ? "decided" : "open");
176
+ // `confirm` asks every time, whatever the stored options say.
177
+ const remember = () =>
178
+ props.item.remember.chat && props.item.assurance === "standard"
179
+ ? {
180
+ label: props.item.remember.website ? actionMessages(card().locale).rememberWebsite : actionMessages(card().locale).remember,
181
+ onSelect: () => props.decide({ approved: true, remember: "chat" }),
182
+ }
183
+ : undefined;
195
184
  return (
196
- <span class="cloud-approval__receipt" data-outcome={props.outcome}>
197
- <i class={outcomeIcon(props.outcome)} aria-hidden="true" />
198
- {label()}
199
- </span>
185
+ <article class="cloud-approval" data-decided={props.outcome} aria-label={t().approvalFor({ title: card().questionText })}>
186
+ <Origin item={props.item} />
187
+ <Armed>
188
+ <ActionQuestion
189
+ card={card()}
190
+ state={state()}
191
+ timeZone={READER_TIME_ZONE}
192
+ remember={remember()}
193
+ onConfirm={() => props.decide({ approved: true })}
194
+ onDecline={() => props.decide({ approved: false })}
195
+ // The outcome lines join the card's sentence, so they speak the stored review's language.
196
+ outcome={
197
+ <ActionReceipt
198
+ card={card()}
199
+ lines={outcomeLines(card(), approvalMessages.resolve([card().locale]).t, props.reopen)}
200
+ current={current()}
201
+ />
202
+ }
203
+ />
204
+ </Armed>
205
+ </article>
200
206
  );
201
207
  }
202
208
 
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 }) {
209
+ /** Reject, then approve, for the compact band; the full card opens in a panel. */
210
+ function BandDecision(props: {
211
+ item: PendingApproval;
212
+ busy: boolean;
213
+ details: () => void;
214
+ decide: (input: ApprovalDecisionInput) => void;
215
+ }) {
205
216
  const t = useMessages();
206
217
  const [armed, setArmed] = createSignal(false);
207
218
  onMount(() => {
208
219
  const timer = setTimeout(() => setArmed(true), ARM_DELAY_MS);
209
220
  onCleanup(() => clearTimeout(timer));
210
221
  });
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
222
  return (
226
223
  // Inert, not disabled, while arming: the buttons look the same from the first frame, so nothing flickers.
227
224
  <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>
225
+ <Button size="sm" variant="ghost" onClick={() => props.details()}>
226
+ {t().details}
227
+ </Button>
235
228
  <Button size="sm" variant="ghost" disabled={props.busy} onClick={() => props.decide({ approved: false })}>
236
229
  {t().reject}
237
230
  </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>
231
+ <Button size="sm" variant="primary" loading={props.busy} onClick={() => props.decide({ approved: true })}>
232
+ {t().approve}
233
+ </Button>
258
234
  </div>
259
235
  );
260
236
  }
261
237
 
262
- function ReviewLinks(props: { item: PendingApproval }) {
238
+ function Receipt(props: { outcome: Outcome }) {
263
239
  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();
240
+ const label = () => ({ approved: t().approved, rejected: t().rejected, elsewhere: t().decidedElsewhere, gone: t().gone })[props.outcome];
290
241
  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>
242
+ <span class="cloud-approval__receipt" data-outcome={props.outcome}>
243
+ <i class={outcomeIcon(props.outcome)} aria-hidden="true" />
244
+ {label()}
245
+ </span>
310
246
  );
311
247
  }
312
248
 
313
- /** Opens the full request in a panel; resolves with the decision made there, if any. */
249
+ /** Opens the full request in a panel as the chat's card; resolves with the decision made there, if any. */
314
250
  const openDetails = (item: PendingApproval, locale: string) =>
315
251
  dialogCore.open<ApprovalDecisionInput>(
316
252
  (close) => {
317
253
  const t = () => approvalMessages.resolve([locale]).t;
254
+ const card = approvalCard(item, locale, READER_TIME_ZONE);
318
255
  return (
319
256
  <PanelDialog>
320
257
  <PanelDialog.Header
@@ -326,25 +263,30 @@ const openDetails = (item: PendingApproval, locale: string) =>
326
263
  />
327
264
  <PanelDialog.Body>
328
265
  <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} />
266
+ <Armed>
267
+ <ActionQuestion
268
+ card={card}
269
+ state="open"
270
+ timeZone={READER_TIME_ZONE}
271
+ remember={
272
+ item.remember.chat && item.assurance === "standard"
273
+ ? {
274
+ label: item.remember.website ? actionMessages(card.locale).rememberWebsite : actionMessages(card.locale).remember,
275
+ onSelect: () => close({ approved: true, remember: "chat" }),
276
+ }
277
+ : undefined
278
+ }
279
+ onConfirm={() => close({ approved: true })}
280
+ onDecline={() => close({ approved: false })}
281
+ outcome={null}
282
+ />
283
+ </Armed>
334
284
  <p class="cloud-approval__callout">
335
285
  <i class="ti ti-info-circle" aria-hidden="true" />
336
286
  {t().exactlyThis}
337
287
  </p>
338
288
  </div>
339
289
  </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
290
  </PanelDialog>
349
291
  );
350
292
  },
@@ -358,6 +300,7 @@ function Row(props: {
358
300
  busy: boolean;
359
301
  failed: boolean;
360
302
  decide: (input: ApprovalDecisionInput) => void;
303
+ reopen: () => void;
361
304
  }) {
362
305
  const t = useMessages();
363
306
  const locale = useLocale();
@@ -365,37 +308,18 @@ function Row(props: {
365
308
  const input = await openDetails(props.item, locale());
366
309
  if (input) props.decide(input);
367
310
  };
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
311
  return (
381
312
  <Show
382
313
  when={props.layout === "band"}
383
314
  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>
315
+ <ApprovalCard
316
+ item={props.item}
317
+ outcome={props.outcome}
318
+ busy={props.busy}
319
+ failed={props.failed}
320
+ decide={props.decide}
321
+ reopen={props.reopen}
322
+ />
399
323
  }
400
324
  >
401
325
  <article class="cloud-approval-band" data-decided={props.outcome} aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
@@ -404,7 +328,24 @@ function Row(props: {
404
328
  <strong>{approvalTitle(props.item)}</strong>
405
329
  <Origin item={props.item} withApp={false} />
406
330
  </div>
407
- <div class="cloud-approval__foot">{foot()}</div>
331
+ <div class="cloud-approval__foot">
332
+ <Show when={props.failed && !props.outcome}>
333
+ <span class="cloud-approval__note" data-tone="danger" role="alert">
334
+ <i class="ti ti-alert-circle" aria-hidden="true" />
335
+ <span>{t().decisionFailed}</span>
336
+ </span>
337
+ </Show>
338
+ <Show
339
+ when={props.outcome}
340
+ fallback={<BandDecision item={props.item} busy={props.busy} details={() => void details()} decide={props.decide} />}
341
+ >
342
+ {(outcome) => (
343
+ <div class="cloud-approval__actions">
344
+ <Receipt outcome={outcome()} />
345
+ </div>
346
+ )}
347
+ </Show>
348
+ </div>
408
349
  </article>
409
350
  </Show>
410
351
  );
@@ -528,6 +469,7 @@ export default function Approvals(props: ApprovalsProps) {
528
469
  busy={busy().has(item.id)}
529
470
  failed={failed().has(item.id)}
530
471
  decide={(input) => void decide(item, input)}
472
+ reopen={() => setFailed((current) => toggle(current, item.id, false))}
531
473
  />
532
474
  )}
533
475
  </For>
@@ -21,27 +21,21 @@ export const approvalMessages = i18n.define({
21
21
  allApprovals: "All approvals",
22
22
  fromChat: ({ title }: { title: string }) => `from chat “${title}”`,
23
23
  fromUntitledChat: "from a chat",
24
- asksEveryTime: "Asks every time",
25
24
  expiresIn: ({ time }: { time: string }) => `Expires ${time}`,
26
25
  details: "Details",
27
26
  reject: "Reject",
28
27
  approve: "Approve",
29
- approveForChat: "Approve for this chat",
30
- allowWebsiteForChat: "Allow this website in this chat",
31
- alwaysApprove: "Always approve",
32
- moreOptions: "More approval options",
33
- decidedHere: "Just now, decided here",
34
- notApplied: "Your decision was not applied",
28
+ notApplied: "Your decision was not applied.",
29
+ sending: "Sending your answer …",
30
+ approvedHere: "Approved.",
31
+ continuesInChat: "The chat it came from carries it out and shows how it ends.",
32
+ declinedHere: "You declined.",
33
+ answerAgain: "Answer again",
35
34
  approved: "Approved",
36
35
  rejected: "Rejected",
37
36
  decidedElsewhere: "Already decided elsewhere",
38
37
  gone: "Expired or withdrawn",
39
38
  decisionFailed: "The decision was not saved. Try again.",
40
- openIn: ({ app }: { app: string }) => `Open in ${app}`,
41
- editIn: ({ app }: { app: string }) => `Edit in ${app}`,
42
- statusIn: ({ app }: { app: string }) => `Status in ${app}`,
43
- preview: "Preview",
44
- download: "Download",
45
39
  exactlyThis: "This approval applies to exactly this Action. If it changes before it runs, Cloud asks again.",
46
40
  approvalFor: ({ title }: { title: string }) => `Approval: ${title}`,
47
41
  close: "Close",
@@ -65,27 +59,21 @@ export const approvalMessages = i18n.define({
65
59
  allApprovals: "Alle Freigaben",
66
60
  fromChat: ({ title }) => `aus Chat „${title}“`,
67
61
  fromUntitledChat: "aus einem Chat",
68
- asksEveryTime: "Fragt immer",
69
62
  expiresIn: ({ time }) => `Läuft ab ${time}`,
70
63
  details: "Details",
71
64
  reject: "Ablehnen",
72
65
  approve: "Freigeben",
73
- approveForChat: "Für diesen Chat freigeben",
74
- allowWebsiteForChat: "Diese Website in diesem Chat erlauben",
75
- alwaysApprove: "Immer freigeben",
76
- moreOptions: "Weitere Freigabeoptionen",
77
- decidedHere: "Gerade eben, hier entschieden",
78
- notApplied: "Deine Entscheidung wurde nicht übernommen",
66
+ notApplied: "Deine Entscheidung wurde nicht übernommen.",
67
+ sending: "Deine Antwort wird gesendet …",
68
+ approvedHere: "Freigegeben.",
69
+ continuesInChat: "Der Chat, aus dem sie kommt, führt sie aus und zeigt, wie sie endet.",
70
+ declinedHere: "Du hast abgelehnt.",
71
+ answerAgain: "Noch einmal antworten",
79
72
  approved: "Freigegeben",
80
73
  rejected: "Abgelehnt",
81
74
  decidedElsewhere: "Schon anderswo entschieden",
82
75
  gone: "Abgelaufen oder zurückgezogen",
83
76
  decisionFailed: "Die Entscheidung wurde nicht gespeichert. Versuche es erneut.",
84
- openIn: ({ app }) => `In ${app} öffnen`,
85
- editIn: ({ app }) => `In ${app} bearbeiten`,
86
- statusIn: ({ app }) => `Status in ${app}`,
87
- preview: "Vorschau",
88
- download: "Herunterladen",
89
77
  exactlyThis: "Diese Freigabe gilt genau für diese Aktion. Ändert sie sich, bevor sie läuft, fragt Cloud neu.",
90
78
  approvalFor: ({ title }) => `Freigabe: ${title}`,
91
79
  close: "Schließen",
@@ -1,3 +1,4 @@
1
+ import { appAccentStyle } from "../ssr/app-appearance";
1
2
  import { createGlobalSearchHost } from "../ssr/global-search-host";
2
3
  import { openCloudResourcePicker } from "./resource-picker";
3
4
 
@@ -7,5 +8,12 @@ import { openCloudResourcePicker } from "./resource-picker";
7
8
  */
8
9
  const params = new URLSearchParams(location.search);
9
10
  const app = params.get("app") ?? undefined;
10
- if (params.has("picker")) void openCloudResourcePicker({ initialAppId: app });
11
- else createGlobalSearchHost(() => []).open(app ? { scope: { appId: app, label: params.get("label") ?? app } } : {});
11
+ if (params.has("picker")) {
12
+ // The page shell renders `<body class="k2b-ui app-accent-scope" style="…">` for an app page.
13
+ const style = appAccentStyle(params.get("accent") ?? undefined);
14
+ if (style) {
15
+ document.body.classList.add("app-accent-scope");
16
+ document.body.style.cssText = style;
17
+ }
18
+ void openCloudResourcePicker({ initialAppId: app });
19
+ } else createGlobalSearchHost(() => []).open(app ? { scope: { appId: app, label: params.get("label") ?? app } } : {});