@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
@@ -0,0 +1,669 @@
1
+ import { dates, fileIcons } from "@k2b/stdlib";
2
+ import {
3
+ Button,
4
+ Chat,
5
+ type ChatDecisionState,
6
+ type ChatOutcomeLine,
7
+ isStructuredDataValue,
8
+ LocaleProvider,
9
+ MarkdownView,
10
+ ReferenceChip,
11
+ StructuredDataPreview,
12
+ } from "@k2b/ui";
13
+ import { Lexer, type Token, type Tokens } from "marked";
14
+ import { createMemo, createSignal, createUniqueId, For, type JSX, Match, Show, Switch } from "solid-js";
15
+ import type { CapabilityPerson, CapabilityPreview } from "../../contracts/capabilities";
16
+ import { formatBytes } from "../../shared/format";
17
+ import type { ActionCard, ActionChip, ActionPreviewModel, ActionText } from "./action-card";
18
+ import { actionMessages } from "./action-messages";
19
+
20
+ /**
21
+ * The look of every Assistant question and its outcome (card v4): one sentence with chips, Cloud's reason line, the
22
+ * thing itself, two answers that name deed and target, and after the answer one outcome line in the same place. The
23
+ * chat, the approvals page, and Code Mode render the same components from an `ActionCard`.
24
+ */
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Sentence
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function PersonChips(props: { chip: Extract<ActionChip, { kind: "person" }> }) {
31
+ // List words such as ", " and " und " come from the locale; each name is its own chip, the rest stays text.
32
+ const parts = createMemo(() => {
33
+ const names = props.chip.names;
34
+ const items =
35
+ props.chip.rest > 0 ? [...names.map((_, index) => `\u0000${index}`), props.chip.more] : names.map((_, index) => `\u0000${index}`);
36
+ return new Intl.ListFormat(props.chip.locale, { type: "conjunction" }).formatToParts(items).map((part) => {
37
+ const match = part.type === "element" ? /^\u0000(\d+)$/.exec(part.value) : null;
38
+ return match ? { name: names[Number(match[1])]! } : { text: part.value };
39
+ });
40
+ });
41
+ return <For each={parts()}>{(part) => ("name" in part ? <ReferenceChip kind="person" name={part.name ?? ""} /> : part.text)}</For>;
42
+ }
43
+
44
+ /** A sentence of text and typed chips, in the app's colour. */
45
+ export function ActionTextView(props: { text: ActionText; accent?: string }) {
46
+ return (
47
+ <For each={props.text}>
48
+ {(part) => (
49
+ <Switch fallback={part.kind === "text" ? part.text : null}>
50
+ <Match when={part.kind === "chip" ? part.chip : undefined}>
51
+ {(chip) => (
52
+ <Switch>
53
+ <Match when={chip().kind === "person" ? (chip() as Extract<ActionChip, { kind: "person" }>) : undefined}>
54
+ {(person) => <PersonChips chip={person()} />}
55
+ </Match>
56
+ <Match when={chip().kind === "time" ? (chip() as Extract<ActionChip, { kind: "time" }>) : undefined}>
57
+ {(time) => <ReferenceChip kind="time" label={time().label} dateTime={time().dateTime} />}
58
+ </Match>
59
+ <Match
60
+ when={
61
+ chip().kind === "object" || chip().kind === "place"
62
+ ? (chip() as Extract<ActionChip, { kind: "object" | "place" }>)
63
+ : undefined
64
+ }
65
+ >
66
+ {(thing) => (
67
+ <ReferenceChip
68
+ kind={thing().kind}
69
+ title={thing().title}
70
+ href={thing().href}
71
+ icon={thing().icon}
72
+ accent={props.accent}
73
+ gone={thing().gone}
74
+ />
75
+ )}
76
+ </Match>
77
+ </Switch>
78
+ )}
79
+ </Match>
80
+ </Switch>
81
+ )}
82
+ </For>
83
+ );
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Preview
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
91
+
92
+ /** A calendar day stays that day everywhere; an instant shows in the reader's zone. */
93
+ const formatWhen = (value: string, locale: string, timeZone: string, withTime = true): string =>
94
+ DAY.test(value)
95
+ ? dates.formatDate(`${value}T12:00:00.000Z`, { locale, timeZone: "UTC" })
96
+ : withTime
97
+ ? dates.formatDateTime(value, { locale, timeZone })
98
+ : dates.formatDate(value, { locale, timeZone });
99
+
100
+ const rangeText = (start: string, end: string | undefined, locale: string, timeZone: string, allDay?: boolean): string => {
101
+ const from = formatWhen(start, locale, timeZone, !allDay);
102
+ if (!end) return from;
103
+ // An all-day event never shows an end time, even when its bounds are instants.
104
+ const sameDay =
105
+ !allDay &&
106
+ !DAY.test(start) &&
107
+ !DAY.test(end) &&
108
+ formatWhen(start, locale, timeZone, false) === formatWhen(end, locale, timeZone, false);
109
+ const to = sameDay ? dates.formatTime(end, { locale, timeZone }) : formatWhen(end, locale, timeZone, !allDay);
110
+ return to === from ? from : `${from} – ${to}`;
111
+ };
112
+
113
+ const personRow = (person: CapabilityPerson) => ({
114
+ ...(person.name ? { name: person.name } : {}),
115
+ ...(person.address ? { address: person.address } : {}),
116
+ });
117
+
118
+ /** Block tokens end their line, so the excerpt keeps the text's lines. */
119
+ const MARKDOWN_BLOCKS = new Set(["heading", "paragraph", "code", "blockquote", "html", "hr"]);
120
+
121
+ /**
122
+ * The words of Markdown as the parser reads them: marks dropped, every literal character kept (an intraword `_` is
123
+ * text), and link targets kept, so a link never hides where it goes.
124
+ */
125
+ const markdownWords = (tokens: readonly Token[]): string =>
126
+ tokens
127
+ .map((token): string => {
128
+ switch (token.type) {
129
+ case "space":
130
+ return "";
131
+ case "link": {
132
+ const text = markdownWords(token.tokens ?? []);
133
+ return text === token.href ? token.href : `${text} (${token.href})`;
134
+ }
135
+ case "image":
136
+ return token.text;
137
+ case "br":
138
+ return "\n";
139
+ case "list":
140
+ return `${(token as Tokens.List).items.map((item) => markdownWords(item.tokens).trimEnd()).join("\n")}\n`;
141
+ case "table": {
142
+ const table = token as Tokens.Table;
143
+ return `${[table.header, ...table.rows].map((row) => row.map((cell) => markdownWords(cell.tokens)).join(" · ")).join("\n")}\n`;
144
+ }
145
+ }
146
+ const words = "tokens" in token && token.tokens ? markdownWords(token.tokens) : "text" in token ? String(token.text) : token.raw;
147
+ return MARKDOWN_BLOCKS.has(token.type) ? `${words.trimEnd()}\n` : words;
148
+ })
149
+ .join("");
150
+
151
+ /** About what one line holds in the narrowest column a card stands in, a 20 rem panel. */
152
+ const EXCERPT_LINE_CHARS = 40;
153
+
154
+ /** Whether a text may need more than the excerpt's three lines; counted alike on the server and in the browser. */
155
+ const mayOverflow = (text: string): boolean =>
156
+ text.split("\n").reduce((lines, line) => lines + Math.max(1, Math.ceil(line.length / EXCERPT_LINE_CHARS)), 0) > 3;
157
+
158
+ /**
159
+ * The first lines of a text. With the app's link, its "read" link follows the preview; without one, a text that may
160
+ * not fit offers to show the rest in place, and any other text stands whole, so nothing a person approves stays out of
161
+ * reach even where the estimate is short, such as for wide characters.
162
+ */
163
+ function PreviewExcerpt(props: { text: string; readMore?: string; label: string }) {
164
+ const id = createUniqueId();
165
+ const [expanded, setExpanded] = createSignal(false);
166
+ const offer = () => !props.readMore && mayOverflow(props.text);
167
+ return (
168
+ <>
169
+ <Chat.Preview.Excerpt id={id} expanded={expanded() || (!props.readMore && !offer())}>
170
+ {props.text}
171
+ </Chat.Preview.Excerpt>
172
+ <Show when={offer() && !expanded()}>
173
+ <Chat.Preview.ShowAll controls={id} onShow={() => setExpanded(true)}>
174
+ {props.label}
175
+ </Chat.Preview.ShowAll>
176
+ </Show>
177
+ </>
178
+ );
179
+ }
180
+
181
+ /** A short excerpt of a body: plain text as it is, Markdown as its words. */
182
+ const excerptText = (body: string, format: "plain" | "markdown"): string =>
183
+ format === "plain" ? body : markdownWords(Lexer.lex(body)).trimEnd();
184
+
185
+ const peopleNames = (people: readonly CapabilityPerson[], locale: string, more = 0): string => {
186
+ const names = people.map((person) => person.name ?? person.address ?? "");
187
+ return new Intl.ListFormat(locale, { type: "conjunction" }).format(
188
+ more > 0 ? [...names, actionMessages(locale).moreCount({ count: more })] : names,
189
+ );
190
+ };
191
+
192
+ const attachmentLook = (name: string) => {
193
+ // The icon name only: its colour comes from the shared file-type tokens, not from a utility class.
194
+ const glyph = fileIcons.getFileIcon({ name, type: "file" }).split(/\s+/)[0] ?? "ti-file";
195
+ return { icon: `ti ${glyph}`, accent: glyph.includes("pdf") ? "var(--k2b-file-type-pdf)" : undefined };
196
+ };
197
+
198
+ /** An app's warning leads with its first short sentence in bold, such as "1 external recipient." */
199
+ function WarningText(props: { text: string }) {
200
+ const split = () => {
201
+ const match = /^(.{1,80}?[.!?])\s+(\S.*)$/s.exec(props.text);
202
+ return match ? { lead: match[1]!, rest: match[2]! } : null;
203
+ };
204
+ return (
205
+ <Show when={split()} fallback={props.text}>
206
+ {(parts) => (
207
+ <>
208
+ <b>{parts().lead}</b> {parts().rest}
209
+ </>
210
+ )}
211
+ </Show>
212
+ );
213
+ }
214
+
215
+ function MessagePreview(props: {
216
+ preview: Extract<CapabilityPreview, { kind: "message" }>;
217
+ locale: string;
218
+ timeZone: string;
219
+ readMore?: string;
220
+ }) {
221
+ const t = () => actionMessages(props.locale);
222
+ const rows = () => {
223
+ const p = props.preview;
224
+ const more = (count?: number) => (count ? { value: <> {t().andMore({ count })}</> } : {});
225
+ return [
226
+ ...(p.from ? [{ label: t().from, people: [personRow(p.from)] }] : []),
227
+ { label: t().to, people: p.to.map(personRow), ...more(p.more?.to) },
228
+ ...(p.cc?.length || p.more?.cc ? [{ label: t().cc, people: (p.cc ?? []).map(personRow), ...more(p.more?.cc) }] : []),
229
+ ...(p.bcc?.length || p.more?.bcc
230
+ ? [{ label: t().bcc, people: (p.bcc ?? []).map(personRow), emphasis: true, ...more(p.more?.bcc) }]
231
+ : []),
232
+ ];
233
+ };
234
+ const note = () => {
235
+ const p = props.preview;
236
+ if (p.sendAt) {
237
+ const [before, after] = t().sendsAt({ at: "\u0000" }).split("\u0000");
238
+ return (
239
+ <>
240
+ {before}
241
+ <b>{formatWhen(p.sendAt, props.locale, props.timeZone)}</b>
242
+ {after}
243
+ </>
244
+ );
245
+ }
246
+ return p.cc?.length || p.bcc?.length || p.more?.cc || p.more?.bcc ? t().sendsNowWithCopies : t().sendsNow;
247
+ };
248
+ return (
249
+ <>
250
+ <Chat.Preview.Header rows={rows()} note={note()} />
251
+ <Chat.Preview.Title>{props.preview.subject || t().noSubject}</Chat.Preview.Title>
252
+ <Show when={props.preview.body.trim()}>
253
+ <PreviewExcerpt text={excerptText(props.preview.body, props.preview.format)} readMore={props.readMore} label={t().readMail} />
254
+ </Show>
255
+ <For each={props.preview.attachments ?? []}>
256
+ {(attachment) => (
257
+ <Chat.Preview.Line
258
+ icon={attachmentLook(attachment.name).icon}
259
+ accent={attachmentLook(attachment.name).accent}
260
+ meta={attachment.size !== undefined ? formatBytes(attachment.size, { locale: props.locale }) : undefined}
261
+ >
262
+ {attachment.name}
263
+ </Chat.Preview.Line>
264
+ )}
265
+ </For>
266
+ <Show when={props.preview.moreAttachments}>
267
+ {(count) => (
268
+ <Chat.Preview.Line icon="ti ti-paperclip" tone="muted">
269
+ {t().moreAttachments({ count: count() })}
270
+ </Chat.Preview.Line>
271
+ )}
272
+ </Show>
273
+ <For each={props.preview.warnings ?? []}>
274
+ {(warning) => (
275
+ <Chat.Preview.Line icon="ti ti-alert-triangle" tone="warning">
276
+ <WarningText text={warning} />
277
+ </Chat.Preview.Line>
278
+ )}
279
+ </For>
280
+ <Show when={props.readMore}>
281
+ {(href) => (
282
+ <Chat.Preview.ReadMore href={href()} title={t().readMoreTitle}>
283
+ {t().readMail}
284
+ </Chat.Preview.ReadMore>
285
+ )}
286
+ </Show>
287
+ </>
288
+ );
289
+ }
290
+
291
+ function ReviewPreviewBody(props: { model: Extract<ActionPreviewModel, { kind: "review" }>; card: ActionCard; readMore?: string }) {
292
+ const t = () => actionMessages(props.model.locale);
293
+ const locale = () => props.model.locale;
294
+ const tz = () => props.model.timeZone;
295
+ const markIcon = (mark?: "added" | "removed") => (mark === "added" ? "ti ti-plus" : mark === "removed" ? "ti ti-minus" : "ti ti-point");
296
+ const readMore = (label: string) => (
297
+ <Show when={props.readMore}>
298
+ {(href) => (
299
+ <Chat.Preview.ReadMore href={href()} title={t().readMoreTitle}>
300
+ {label}
301
+ </Chat.Preview.ReadMore>
302
+ )}
303
+ </Show>
304
+ );
305
+ const preview = () => props.model.preview;
306
+ return (
307
+ <Chat.Preview>
308
+ <Switch>
309
+ <Match when={preview()?.kind === "message" ? (preview() as Extract<CapabilityPreview, { kind: "message" }>) : undefined}>
310
+ {(message) => <MessagePreview preview={message()} locale={locale()} timeZone={tz()} readMore={props.readMore} />}
311
+ </Match>
312
+ <Match when={preview()?.kind === "event" ? (preview() as Extract<CapabilityPreview, { kind: "event" }>) : undefined}>
313
+ {(event) => (
314
+ <>
315
+ <Chat.Preview.Title>{event().title}</Chat.Preview.Title>
316
+ {/* When, where, and in which calendar, as one line of text like the person would say it. Times show in the
317
+ reader's zone, the same as the sentence's chips, so the question and the preview never disagree. */}
318
+ <p>
319
+ {[
320
+ `${rangeText(event().start, event().end, locale(), tz(), event().allDay)}${
321
+ event().allDay && !DAY.test(event().start) ? ` · ${t().allDay}` : ""
322
+ }`,
323
+ event().location,
324
+ event().place,
325
+ ]
326
+ .filter(Boolean)
327
+ .join(", ")}
328
+ </p>
329
+ <Show when={event().recurrence}>
330
+ {(recurrence) => <Chat.Preview.Line icon="ti ti-repeat">{recurrence()}</Chat.Preview.Line>}
331
+ </Show>
332
+ <Show when={event().attendees?.length ? event().attendees : undefined}>
333
+ {(attendees) => (
334
+ <Chat.Preview.People
335
+ people={attendees()
336
+ .slice(0, 3)
337
+ .map((person) => ({ name: person.name ?? person.address ?? "" }))}
338
+ >
339
+ {peopleNames(attendees().slice(0, 3), locale(), attendees().length - 3)}
340
+ </Chat.Preview.People>
341
+ )}
342
+ </Show>
343
+ <Show when={event().description?.trim()}>
344
+ {(text) => <PreviewExcerpt text={text()} readMore={props.readMore} label={t().readMore} />}
345
+ </Show>
346
+ {readMore(t().readMore)}
347
+ </>
348
+ )}
349
+ </Match>
350
+ <Match when={preview()?.kind === "task" ? (preview() as Extract<CapabilityPreview, { kind: "task" }>) : undefined}>
351
+ {(task) => (
352
+ <>
353
+ <Chat.Preview.Title meta={task().place}>{task().title}</Chat.Preview.Title>
354
+ <Show when={task().due}>
355
+ {(due) => (
356
+ <Chat.Preview.Line icon="ti ti-calendar-due">{t().due({ at: formatWhen(due(), locale(), tz()) })}</Chat.Preview.Line>
357
+ )}
358
+ </Show>
359
+ <Show when={task().status}>{(status) => <Chat.Preview.Line icon="ti ti-progress">{status()}</Chat.Preview.Line>}</Show>
360
+ <Show when={task().assignees?.length ? task().assignees : undefined}>
361
+ {(assignees) => (
362
+ <Chat.Preview.People
363
+ people={assignees()
364
+ .slice(0, 3)
365
+ .map((person) => ({ name: person.name ?? person.address ?? "" }))}
366
+ >
367
+ {peopleNames(assignees().slice(0, 3), locale(), assignees().length - 3)}
368
+ </Chat.Preview.People>
369
+ )}
370
+ </Show>
371
+ <Show when={task().checklist?.length ? task().checklist : undefined}>
372
+ {(checklist) => (
373
+ <Chat.Preview.Lines>
374
+ <For each={checklist()}>
375
+ {(item) => (
376
+ <Chat.Preview.Line icon={item.done ? "ti ti-square-check" : "ti ti-square"}>{item.label}</Chat.Preview.Line>
377
+ )}
378
+ </For>
379
+ </Chat.Preview.Lines>
380
+ )}
381
+ </Show>
382
+ <Show when={task().tags?.length ? task().tags : undefined}>
383
+ {(tags) => (
384
+ <Chat.Preview.Line icon="ti ti-tag" tone="muted">
385
+ {tags().join(", ")}
386
+ </Chat.Preview.Line>
387
+ )}
388
+ </Show>
389
+ <Show when={task().description?.trim()}>
390
+ {(text) => <PreviewExcerpt text={text()} readMore={props.readMore} label={t().readMore} />}
391
+ </Show>
392
+ {readMore(t().readMore)}
393
+ </>
394
+ )}
395
+ </Match>
396
+ <Match when={preview()?.kind === "page" ? (preview() as Extract<CapabilityPreview, { kind: "page" }>) : undefined}>
397
+ {(page) => (
398
+ <>
399
+ <Chat.Preview.Title meta={page().place}>{page().title}</Chat.Preview.Title>
400
+ <Show when={page().body.trim()}>
401
+ <PreviewExcerpt text={excerptText(page().body, page().format)} readMore={props.readMore} label={t().readPage} />
402
+ </Show>
403
+ <Show when={page().changes?.length ? page().changes : undefined}>
404
+ {(changes) => (
405
+ <Chat.Preview.Lines label={t().changes}>
406
+ <For each={changes()}>
407
+ {(change) => (
408
+ <Chat.Preview.Line icon={markIcon(change.change)} mark={change.change}>
409
+ {change.text}
410
+ </Chat.Preview.Line>
411
+ )}
412
+ </For>
413
+ </Chat.Preview.Lines>
414
+ )}
415
+ </Show>
416
+ {readMore(t().readPage)}
417
+ </>
418
+ )}
419
+ </Match>
420
+ <Match when={preview()?.kind === "file" ? (preview() as Extract<CapabilityPreview, { kind: "file" }>) : undefined}>
421
+ {(file) => (
422
+ <>
423
+ <Chat.Preview.Title
424
+ meta={
425
+ [file().size !== undefined ? formatBytes(file().size, { locale: locale() }) : "", file().place ?? file().path ?? ""]
426
+ .filter(Boolean)
427
+ .join(" · ") || undefined
428
+ }
429
+ >
430
+ {file().name}
431
+ </Chat.Preview.Title>
432
+ <Show when={file().to}>
433
+ {(to) => <Chat.Preview.Line icon="ti ti-arrow-right">{t().moveTo({ to: to() })}</Chat.Preview.Line>}
434
+ </Show>
435
+ {readMore(t().readMore)}
436
+ </>
437
+ )}
438
+ </Match>
439
+ <Match when={preview()?.kind === "rule" ? (preview() as Extract<CapabilityPreview, { kind: "rule" }>) : undefined}>
440
+ {(rule) => (
441
+ <>
442
+ <Chat.Preview.Sentence>
443
+ <ActionTextView text={props.model.when ?? []} accent={props.card.app.accent} />
444
+ </Chat.Preview.Sentence>
445
+ <Show when={rule().does.trim()}>
446
+ <PreviewExcerpt text={rule().does} readMore={props.readMore} label={t().readRule} />
447
+ </Show>
448
+ {readMore(t().readRule)}
449
+ <Chat.Preview.Lines label={rule().seesChat ? t().changes : t().ruleMay}>
450
+ <For each={props.model.may ?? []}>
451
+ {(line) => (
452
+ <Chat.Preview.Line icon={line.mark === "removed" ? "ti ti-minus" : "ti ti-check"} mark={line.mark}>
453
+ <ActionTextView text={line.text} accent={props.card.app.accent} />
454
+ </Chat.Preview.Line>
455
+ )}
456
+ </For>
457
+ {/* What a routine cannot know is Cloud's fixed line; a Skill that works inside chats needs none. */}
458
+ <Show when={!rule().seesChat}>
459
+ <Chat.Preview.Line icon="ti ti-info-circle" tone="muted">
460
+ {t().ruleBlindToChat}
461
+ </Chat.Preview.Line>
462
+ </Show>
463
+ </Chat.Preview.Lines>
464
+ </>
465
+ )}
466
+ </Match>
467
+ <Match when={preview()?.kind === "fields" ? (preview() as Extract<CapabilityPreview, { kind: "fields" }>) : undefined}>
468
+ {(fields) => {
469
+ const value = (text: string, format?: "date" | "date-time") =>
470
+ format ? formatWhen(text, locale(), tz(), format === "date-time") : text;
471
+ return (
472
+ <>
473
+ <Chat.Preview.Header
474
+ rows={fields().rows.map((row) => ({
475
+ label: row.label,
476
+ value:
477
+ row.before !== undefined ? (
478
+ <>
479
+ <Chat.Mark change="removed">{value(row.before, row.format)}</Chat.Mark>{" "}
480
+ <Chat.Mark change="added">{value(row.value, row.format)}</Chat.Mark>
481
+ </>
482
+ ) : (
483
+ <span class="cloud-action-preview__value">{value(row.value, row.format)}</span>
484
+ ),
485
+ }))}
486
+ />
487
+ {readMore(t().readMore)}
488
+ </>
489
+ );
490
+ }}
491
+ </Match>
492
+ </Switch>
493
+ <Show when={!preview()}>{readMore(t().readMore)}</Show>
494
+ <Show when={props.model.consequences.length > 0}>
495
+ <Chat.Preview.Lines label={t().changes}>
496
+ <For each={props.model.consequences}>
497
+ {(line) => (
498
+ <Chat.Preview.Line icon={markIcon(line.mark)} mark={line.mark}>
499
+ <ActionTextView text={line.text} accent={props.card.app.accent} />
500
+ </Chat.Preview.Line>
501
+ )}
502
+ </For>
503
+ </Chat.Preview.Lines>
504
+ </Show>
505
+ </Chat.Preview>
506
+ );
507
+ }
508
+
509
+ function LegacyPreview(props: { model: Extract<ActionPreviewModel, { kind: "legacy" }>; card: ActionCard; timeZone: string }) {
510
+ const t = () => actionMessages(props.card.locale);
511
+ const [inputOpen, setInputOpen] = createSignal(false);
512
+ const value = (row: { value: string; format?: "date" | "date-time" }) =>
513
+ row.format ? formatWhen(row.value, props.card.locale, props.timeZone, row.format === "date-time") : row.value;
514
+ const input = () => (isStructuredDataValue(props.model.input) ? props.model.input : undefined);
515
+ return (
516
+ <Chat.Preview class="cloud-action-preview--legacy">
517
+ <Show when={props.model.rows.length > 0}>
518
+ <Chat.Preview.Header
519
+ rows={props.model.rows.map((row) => ({
520
+ label: row.label,
521
+ value: row.format ? (
522
+ <time dateTime={row.value}>{value(row)}</time>
523
+ ) : (
524
+ <span class="cloud-action-preview__value">{value(row)}</span>
525
+ ),
526
+ }))}
527
+ />
528
+ </Show>
529
+ <Show when={props.model.markdown}>
530
+ {(markdown) => (
531
+ <MarkdownView class="cloud-action-preview__markdown" markdown={markdown()} headingScale="compact" allowImages={false} />
532
+ )}
533
+ </Show>
534
+ <Show when={props.model.input !== undefined}>
535
+ <div class="cloud-action-preview__input">
536
+ <Button variant="text" size="sm" aria-expanded={inputOpen()} onClick={() => setInputOpen((open) => !open)}>
537
+ {inputOpen() ? t().hideInput : t().showInput}
538
+ </Button>
539
+ <Show when={inputOpen()}>
540
+ <div role="region" aria-label={t().input}>
541
+ <Show when={input()} fallback={<pre class="cloud-action-preview__raw">{JSON.stringify(props.model.input, null, 2)}</pre>}>
542
+ {(data) => <StructuredDataPreview data={data()} class="w-full" />}
543
+ </Show>
544
+ </div>
545
+ </Show>
546
+ </div>
547
+ </Show>
548
+ <Show when={props.card.readMore}>
549
+ {(href) => (
550
+ <Chat.Preview.ReadMore href={href()} title={t().readMoreTitle}>
551
+ {t().readMore}
552
+ </Chat.Preview.ReadMore>
553
+ )}
554
+ </Show>
555
+ </Chat.Preview>
556
+ );
557
+ }
558
+
559
+ /** The thing the question is about, from its review, or an old review's rows and Markdown. */
560
+ export function ActionPreview(props: { card: ActionCard; timeZone: string }) {
561
+ return (
562
+ <>
563
+ <Show when={props.card.preview}>
564
+ {(model) => (
565
+ <Switch>
566
+ <Match when={model().kind === "review" ? (model() as Extract<ActionPreviewModel, { kind: "review" }>) : undefined}>
567
+ {(review) => <ReviewPreviewBody model={review()} card={props.card} readMore={props.card.readMore} />}
568
+ </Match>
569
+ <Match when={model().kind === "legacy" ? (model() as Extract<ActionPreviewModel, { kind: "legacy" }>) : undefined}>
570
+ {(legacy) => <LegacyPreview model={legacy()} card={props.card} timeZone={props.timeZone} />}
571
+ </Match>
572
+ </Switch>
573
+ )}
574
+ </Show>
575
+ {/* The review's other links, such as editing the draft, stay one quiet click away before the answer. */}
576
+ <Show when={props.card.links.length > 0 || (!props.card.preview && props.card.readMore)}>
577
+ <Chat.Preview class="cloud-action-preview__links">
578
+ <Show when={!props.card.preview && props.card.readMore}>
579
+ {(href) => (
580
+ <Chat.Preview.ReadMore href={href()} title={actionMessages(props.card.locale).readMoreTitle}>
581
+ {actionMessages(props.card.locale).readMore}
582
+ </Chat.Preview.ReadMore>
583
+ )}
584
+ </Show>
585
+ <For each={props.card.links}>{(link) => <Chat.Preview.ReadMore href={link.href}>{link.label}</Chat.Preview.ReadMore>}</For>
586
+ </Chat.Preview>
587
+ </Show>
588
+ </>
589
+ );
590
+ }
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // Outcome and question
594
+ // ---------------------------------------------------------------------------
595
+
596
+ /** One outcome line: what happened, in the card's sentence, with Cloud's detail under it. */
597
+ export type ActionLine = Omit<ChatOutcomeLine, "text" | "detail"> & { text: ActionText; detail?: JSX.Element };
598
+
599
+ /**
600
+ * The outcome line of one call. Pass every line one flow can reach (running and done, or the lines of a check): they
601
+ * share one cell, so switching between them moves nothing.
602
+ */
603
+ export function ActionReceipt(props: {
604
+ card: ActionCard;
605
+ lines: Readonly<Record<string, ActionLine>>;
606
+ current: string;
607
+ class?: string;
608
+ anchorId?: string | number;
609
+ }) {
610
+ const lines = createMemo(() =>
611
+ Object.fromEntries(
612
+ Object.entries(props.lines).map(([key, line]) => [
613
+ key,
614
+ { ...line, text: <ActionTextView text={line.text} accent={props.card.app.accent} /> } satisfies ChatOutcomeLine,
615
+ ]),
616
+ ),
617
+ );
618
+ return (
619
+ <LocaleProvider locale={props.card.locale}>
620
+ <Chat.Outcome class={`cloud-action-outcome ${props.class ?? ""}`} lines={lines()} current={props.current} anchorId={props.anchorId} />
621
+ </LocaleProvider>
622
+ );
623
+ }
624
+
625
+ export type ActionQuestionProps = {
626
+ card: ActionCard;
627
+ /** `open` shows the question; every other state shows `outcome` in its place. */
628
+ state: ChatDecisionState;
629
+ /** Usually `ActionReceipt`. */
630
+ outcome: JSX.Element;
631
+ timeZone: string;
632
+ onConfirm: () => void | Promise<void>;
633
+ onDecline: () => void | Promise<void>;
634
+ /** The quiet third answer, where remembering for this chat exists. */
635
+ remember?: { label: string; onSelect: () => void | Promise<void> };
636
+ /** Turns the confirming answers off; this reason replaces the asking reason in its place. */
637
+ unavailable?: string;
638
+ anchorId?: string | number;
639
+ class?: string;
640
+ };
641
+
642
+ const confirmIcon = (card: ActionCard): string | undefined =>
643
+ card.reason.code === "sends" ? "ti ti-send" : card.confirm.variant === "danger" ? "ti ti-trash" : undefined;
644
+
645
+ /** One Assistant question in card v4. An answer collapses it at once to its outcome line, which takes the focus. */
646
+ export function ActionQuestion(props: ActionQuestionProps) {
647
+ return (
648
+ <LocaleProvider locale={props.card.locale}>
649
+ <Chat.Decision
650
+ class={`cloud-action-question ${props.class ?? ""}`}
651
+ state={props.state}
652
+ anchorId={props.anchorId}
653
+ sentence={<ActionTextView text={props.card.question} accent={props.card.app.accent} />}
654
+ reason={{ text: props.card.reason.text, icon: props.card.reason.icon, tone: props.card.reason.tone }}
655
+ unavailable={props.unavailable ? { text: props.unavailable, icon: "ti ti-player-pause", tone: "neutral" } : undefined}
656
+ preview={<ActionPreview card={props.card} timeZone={props.timeZone} />}
657
+ confirm={{
658
+ label: props.card.confirm.label,
659
+ variant: props.card.confirm.variant,
660
+ icon: confirmIcon(props.card),
661
+ onSelect: props.onConfirm,
662
+ }}
663
+ decline={{ label: props.card.decline, onSelect: props.onDecline }}
664
+ remember={props.remember}
665
+ outcome={props.outcome}
666
+ />
667
+ </LocaleProvider>
668
+ );
669
+ }