@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,15 +1,4 @@
1
1
  import { createContext, useContext } from "solid-js";
2
- import {
3
- type CapabilityActionWording,
4
- capabilityActionOutcome,
5
- capabilityActionSubject,
6
- resolveCapabilityActionSentences,
7
- } from "../../_internal/capability-sentences";
8
- import type { AiTurnBlock } from "../protocol";
9
- import { CLOUD_AI_TOOL_PRESENTATION } from "../tool-sentences";
10
- import { displayToolName, isRecord } from "./message-utils";
11
-
12
- type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
13
2
 
14
3
  const ReaderTimeZoneContext = createContext<string>();
15
4
 
@@ -20,36 +9,4 @@ export const AiReaderTimeZoneProvider = ReaderTimeZoneContext.Provider;
20
9
  export const useReaderTimeZone = (): string =>
21
10
  useContext(ReaderTimeZoneContext) ?? (typeof window === "undefined" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone);
22
11
 
23
- /**
24
- * How the chat words one call that acts on something: an app Action through its saved presentation, a
25
- * Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
26
- */
27
- export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
28
- const presentation = block.presentation;
29
- if (presentation?.kind === "capability") {
30
- if (presentation.capabilityKind !== "action") return null;
31
- return {
32
- title: presentation.title,
33
- ...(presentation.sentences ? { sentences: presentation.sentences } : {}),
34
- ...(presentation.fields ? { fields: presentation.fields } : {}),
35
- };
36
- }
37
- const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
38
- return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
39
- };
40
-
41
- /** What the call does or would do, as one sentence; `null` for calls that only read. */
42
- export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
43
- const wording = toolWording(block, locale);
44
- return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
45
- };
46
-
47
- /** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
48
- export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
49
- const wording = toolWording(block, locale);
50
- if (!wording) return null;
51
- const result = isRecord(block.result) ? block.result : undefined;
52
- // App Actions return their data inside the result envelope; Cloud tools return the data itself.
53
- const data = block.presentation?.kind === "capability" ? result?.data : block.result;
54
- return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
55
- };
12
+ export { toolOutcome, toolSubject, toolWording } from "../tool-wording";
@@ -3,6 +3,7 @@ import { mutation } from "@k2b/stdlib/solid";
3
3
  import { Button, ButtonLink, Chat, isStructuredDataValue, MarkdownView, SplitButton, StructuredDataPreview, useLocale } from "@k2b/ui";
4
4
  import { createSignal, createUniqueId, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
5
5
  import { capabilityApprovalReason, capabilityApprovalReasonLabel } from "../../_internal/capability-sentences";
6
+ import { capabilityReviewView } from "../../capabilities/reviews";
6
7
  import type { CapabilityActionReview } from "../../contracts/capabilities";
7
8
  import { markdown } from "../../shared";
8
9
  import type { AiTurnBlock } from "../protocol";
@@ -31,8 +32,10 @@ import {
31
32
  } from "./message-utils";
32
33
  import { aiChatMessages } from "./messages";
33
34
  import { AssistantMarkdownBlock } from "./primitives";
35
+ import { ToolActionView } from "./tool-action";
34
36
  import { AiToolActivity } from "./tool-disclosure";
35
37
  import { isFailedTool } from "./tool-groups";
38
+ import type { AiTurnActionState } from "./turn-layout";
36
39
  import { CloudCardBlock, CloudSurveyBlock, CloudSurveyResultBlock, CloudTextEditorBlock, CloudTextEditorResultBlock } from "./visual-tools";
37
40
  import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock, WebsiteReceipts, websiteReceipts } from "./web-tools";
38
41
 
@@ -199,6 +202,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
199
202
  const locale = useLocale();
200
203
  const timeZone = useReaderTimeZone();
201
204
  const t = () => aiChatMessages(locale());
205
+ const review = () => (props.block.approval?.review ? capabilityReviewView(props.block.approval.review, { timeZone }) : undefined);
202
206
  const request = () => ({ turnId: props.turnId, callId: props.block.callId, name: props.block.name });
203
207
  const pending = () => props.block.status === "awaiting_approval";
204
208
  const actionDisabled = () => actions.actionDisabled?.() ?? false;
@@ -229,7 +233,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
229
233
  // The model's own reason, labelled as such, only where Cloud offered it; it adds to the app's sentence and never replaces it.
230
234
  const reason = () => (props.block.presentation?.approvalReason ? capabilityApprovalReason(props.block.args) : null);
231
235
  const description = () => {
232
- const reviewMessage = props.block.approval?.review?.message.trim();
236
+ const reviewMessage = review()?.message.trim();
233
237
  if (reviewMessage) return reviewMessage;
234
238
  // An app Action's approval text repeats its heading and reason for text-only readers; the card shows them itself.
235
239
  if (capability()) return null;
@@ -252,10 +256,9 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
252
256
  const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
253
257
  return match ? { label: match[1], value: match[2] } : { value: line };
254
258
  });
255
- const inlineReviewDetails = () =>
256
- (props.block.approval?.review?.details ?? []).filter((detail) => (detail.display ?? "inline") === "inline");
257
- const blockReviewDetails = () => (props.block.approval?.review?.details ?? []).filter((detail) => detail.display === "block");
258
- const reviewLinks = () => props.block.approval?.review?.links ?? [];
259
+ const inlineReviewDetails = () => (review()?.details ?? []).filter((detail) => (detail.display ?? "inline") === "inline");
260
+ const blockReviewDetails = () => (review()?.details ?? []).filter((detail) => detail.display === "block");
261
+ const reviewLinks = () => review()?.links ?? [];
259
262
  const reviewLinkTitle = (rel: "open" | "edit" | "status" | "preview" | "download") =>
260
263
  ({
261
264
  open: t().openIn({ app: ownerName() }),
@@ -545,20 +548,6 @@ function CapabilityToolView(props: { block: ToolBlock }) {
545
548
  );
546
549
  }
547
550
 
548
- function RejectedToolView(props: { block: ToolBlock }) {
549
- const locale = useLocale();
550
- const timeZone = useReaderTimeZone();
551
- const presentation = () => props.block.presentation;
552
- const title = () => toolSubject(props.block, locale(), timeZone) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
553
- return (
554
- <Chat.Activity
555
- icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
556
- label={toolOutcome(props.block, "rejected", locale(), timeZone) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
557
- accent={presentation()?.appAccent}
558
- />
559
- );
560
- }
561
-
562
551
  export function SurveyToolView(props: { turnId: string; block: ToolBlock; active?: boolean }) {
563
552
  const actions = useAiChatActions();
564
553
  const locale = useLocale();
@@ -670,6 +659,15 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
670
659
  const actions = useAiChatActions();
671
660
  const locale = useLocale();
672
661
  const status = () => props.block.status;
662
+ // The same states as place 4 of a turn: open, then running, done, rejected, failed, or not run.
663
+ const actionState = (): AiTurnActionState | "open" | undefined => {
664
+ if (status() === "awaiting_approval") return "open";
665
+ if (status() === "rejected") return "rejected";
666
+ const action = props.block.presentation?.kind === "capability" && props.block.presentation.capabilityKind === "action";
667
+ if (!action) return undefined;
668
+ if (status() === "running" || status() === "awaiting_client") return props.active === false ? "not_run" : "running";
669
+ return isFailedTool(props.block) ? "failed" : "done";
670
+ };
673
671
  const fetchError = () =>
674
672
  props.block.name === "fetch_file" && props.block.isError ? fetchFileErrorPresentation(props.block.result, locale()) : undefined;
675
673
  return (
@@ -688,12 +686,8 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
688
686
  />
689
687
  }
690
688
  >
691
- <Match when={status() === "awaiting_approval"}>
692
- <ApprovalBlockView turnId={props.turnId} block={props.block} />
693
- </Match>
694
- <Match when={status() === "rejected"}>
695
- <RejectedToolView block={props.block} />
696
- </Match>
689
+ {/* A question, and every outcome of an app Action, is one card v4 element. */}
690
+ <Match when={actionState()}>{(state) => <ToolActionView block={props.block} turnId={props.turnId} state={state()} />}</Match>
697
691
  <Match when={props.block.presentation?.kind === "capability"}>
698
692
  <CapabilityToolView block={props.block} />
699
693
  </Match>
@@ -12,6 +12,7 @@ import {
12
12
  } from "@k2b/ui";
13
13
  import { createContext, createSignal, For, type JSX, Show, useContext } from "solid-js";
14
14
  import type { CloudResourceRef } from "../../contracts/capabilities";
15
+ import type { AiActionCheck } from "../action-review";
15
16
  import type { AiTurnBlock } from "../protocol";
16
17
  import type { AiMessageFeedback, AiMessageFeedbackReason, AiStoredMessage } from "../types";
17
18
  import {
@@ -46,6 +47,11 @@ export type AiChatActions = {
46
47
  /** Revoke the approval that lets this chat read `origin` without asking; the next request asks again. */
47
48
  onRevokeWebsite?: (origin: string) => Promise<void>;
48
49
  onFrontendToolResult?: FrontendToolResultHandler;
50
+ /**
51
+ * Ask the owning app once more whether a call whose outcome stayed unknown happened, through its `check` Query. Never
52
+ * repeats the call itself. Resolves with the stored check; without it, an unknown outcome offers no new check.
53
+ */
54
+ onCheckAction?: (request: AiTurnActionRequest) => Promise<AiActionCheck>;
49
55
  onForkMessage?: ForkMessageHandler;
50
56
  onRetryMessage?: RetryMessageHandler;
51
57
  onRetrySteer?: RetrySteerHandler;
@@ -0,0 +1,481 @@
1
+ import { dates } from "@k2b/stdlib";
2
+ import { Button, ButtonLink, type ChatDecisionState, useLocale } from "@k2b/ui";
3
+ import { createEffect, createMemo, createSignal, For, type JSX, on, onCleanup, Show } from "solid-js";
4
+ import { capabilityUnknownText } from "../../_internal/capability-sentences";
5
+ import { CapabilityActionReceiptSchema } from "../../contracts/capabilities";
6
+ import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck } from "../action-review";
7
+ import type { AiTurnBlock } from "../protocol";
8
+ import { toolOutcome } from "../tool-wording";
9
+ import { type ActionCard, type ActionText, actionReceiptText, toolActionCard } from "./action-card";
10
+ import { actionMessages } from "./action-messages";
11
+ import { type ActionLine, ActionQuestion, ActionReceipt } from "./action-question";
12
+ import { useReaderTimeZone } from "./action-sentences";
13
+ import { useAiChatActions } from "./message-actions";
14
+ import { capabilityErrorDescription, isRecord } from "./message-utils";
15
+ import { aiChatMessages } from "./messages";
16
+ import type { AiTurnActionState, AiTurnPhase } from "./turn-layout";
17
+
18
+ type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
19
+
20
+ /**
21
+ * Codes that say Cloud could not see whether the Action happened; nothing may repeat it blindly. Core rewrites a
22
+ * failure after it reached the app to ACTION_OUTCOME_UNKNOWN, so a bare transport code such as APP_UNAVAILABLE failed
23
+ * before the app saw anything, or comes from older history; either way it reads as a failure with its message.
24
+ */
25
+ const UNKNOWN_CODES = new Set(["ACTION_OUTCOME_UNKNOWN", "IDEMPOTENCY_UNCERTAIN", "IDEMPOTENCY_IN_PROGRESS"]);
26
+
27
+ /** Cloud's own transport codes: they say the call failed, not that the app refused it, so they never claim "not run". */
28
+ const TRANSPORT_CODES = new Set([
29
+ "DEADLINE_EXCEEDED",
30
+ "APP_UNAVAILABLE",
31
+ "REQUEST_CANCELLED",
32
+ "INVALID_APP_RESPONSE",
33
+ "RESPONSE_TOO_LARGE",
34
+ "INTERNAL",
35
+ ]);
36
+
37
+ /** The framework code of a failed call, from `CODE: message` or a structured error. */
38
+ export const toolErrorCode = (result: unknown): string | null => {
39
+ if (typeof result === "string") return /^([A-Z][A-Z0-9_]{2,}):/.exec(result.trim())?.[1] ?? null;
40
+ if (isRecord(result) && typeof result.code === "string") return result.code;
41
+ return null;
42
+ };
43
+
44
+ /** The message without its code, so the detail under a line reads as a sentence. */
45
+ const errorDetail = (result: unknown): string => capabilityErrorDescription(result).replace(/^[A-Z][A-Z0-9_]{2,}:\s*/, "");
46
+
47
+ /** The application's own short summary of a result, if it fits on one line. */
48
+ const resultSummary = (block: ToolBlock): string | undefined => {
49
+ const value = isRecord(block.result) ? block.result.summary : undefined;
50
+ return typeof value === "string" && value.trim() && value.trim().length <= 500 ? value.trim() : undefined;
51
+ };
52
+
53
+ /** The words of a call without an app sentence: Cloud's frames around its catalog sentence or title. */
54
+ export const legacyOutcomeWords = (block: ToolBlock, locale: string, timeZone: string) => (subject: string) => {
55
+ const t = aiChatMessages(locale);
56
+ const capability = block.presentation?.kind === "capability";
57
+ return {
58
+ running: t.receiptRunning({ title: subject }),
59
+ // The app's done sentence, else its own summary of the call, else Cloud's frame.
60
+ done:
61
+ toolOutcome(block, "done", locale, timeZone) ??
62
+ resultSummary(block) ??
63
+ (capability ? t.receiptDone({ title: subject }) : t.receiptApproved({ title: subject })),
64
+ rejected: toolOutcome(block, "rejected", locale, timeZone) ?? t.receiptRejected({ title: subject }),
65
+ notRun: toolOutcome(block, "notRun", locale, timeZone) ?? t.receiptNotRun({ title: subject, stopped: false }),
66
+ unknown: capabilityUnknownText(subject, locale),
67
+ unknownStill: capabilityUnknownText(subject, locale, true),
68
+ };
69
+ };
70
+
71
+ /** The card of one tool block in the reader's locale and zone. */
72
+ export const useToolActionCard = (block: () => ToolBlock): (() => ActionCard) => {
73
+ const locale = useLocale();
74
+ const timeZone = useReaderTimeZone();
75
+ return createMemo(() =>
76
+ toolActionCard(block(), { locale: locale(), timeZone, outcome: legacyOutcomeWords(block(), locale(), timeZone) }),
77
+ );
78
+ };
79
+
80
+ type LocalAnswer = "approve" | "decline" | null;
81
+
82
+ /** Which outcome a call reached and what the person did here, as one key per flow. */
83
+ type Flow =
84
+ | { flow: "run"; current: "running" | "done" }
85
+ | { flow: "rejected" }
86
+ | { flow: "expired" }
87
+ | { flow: "stopped" }
88
+ | { flow: "error" }
89
+ | { flow: "failed" }
90
+ | { flow: "lost" }
91
+ | {
92
+ flow: "check";
93
+ current: "checking" | "checkedDone" | "checkedNotDone" | "pending" | "checkFailed" | "noCheck" | "cutOff";
94
+ };
95
+
96
+ const flowOf = (input: {
97
+ block: ToolBlock;
98
+ state: AiTurnActionState | "open";
99
+ answer: LocalAnswer;
100
+ lost: boolean;
101
+ check: AiActionCheck | undefined;
102
+ phase?: AiTurnPhase;
103
+ /** The stored claim of a running check is older than the stale limit. */
104
+ staleCheck?: boolean;
105
+ }): Flow => {
106
+ const { block, check } = input;
107
+ // A lost answer holds only while the question still waits; the server's own outcome always wins over it.
108
+ if (input.lost && block.status === "awaiting_approval") return { flow: "lost" };
109
+ if (block.status === "awaiting_approval") return input.answer === "decline" ? { flow: "rejected" } : { flow: "run", current: "running" };
110
+ // A check runs only inside a live turn and only for a while; one that history still shows as running, or whose
111
+ // claim went stale, was cut off and is open again.
112
+ if (check?.state === "checking")
113
+ return input.staleCheck || input.phase === "completed" || input.phase === "stopped" || input.phase === "failed"
114
+ ? { flow: "check", current: "pending" }
115
+ : { flow: "check", current: "checking" };
116
+ if (check?.state === "done") return { flow: "check", current: "checkedDone" };
117
+ if (check?.state === "not_done") return { flow: "check", current: "checkedNotDone" };
118
+ // An open check stays open whatever the call's own end looked like, such as a turn stopped while it checked.
119
+ if (check?.state === "pending") return { flow: "check", current: "pending" };
120
+ if (check?.state === "failed") return { flow: "check", current: "checkFailed" };
121
+ if (input.state === "rejected") return { flow: "rejected" };
122
+ // The person's stop ends an open question or a call it cut off; a question nobody answered expired. A call that was
123
+ // still running when the turn ended any other way may have reached the app, so its outcome is open.
124
+ if (input.state === "not_run") {
125
+ if (input.phase === "stopped") return { flow: "stopped" };
126
+ return block.approved === false ? { flow: "expired" } : { flow: "check", current: "cutOff" };
127
+ }
128
+ if (input.state === "failed") {
129
+ const code = toolErrorCode(block.result);
130
+ if (code === "ACTION_NOT_DONE") return { flow: "check", current: "checkedNotDone" };
131
+ if (code && UNKNOWN_CODES.has(code)) {
132
+ return { flow: "check", current: "noCheck" };
133
+ }
134
+ // A failure with an app's code was refused before anything happened; a transport code or none proves nothing.
135
+ return code && !TRANSPORT_CODES.has(code) ? { flow: "error" } : { flow: "failed" };
136
+ }
137
+ if (input.state === "done") return { flow: "run", current: "done" };
138
+ return { flow: "run", current: "running" };
139
+ };
140
+
141
+ /**
142
+ * One call in place 4: the open question, then, in the same element, its outcome line. An answer here collapses the
143
+ * question at once to the running or declined line and moves the focus there; the server's outcome replaces it in the
144
+ * same cell. A call that never asked shows only its outcome line.
145
+ */
146
+ export function ToolActionView(props: {
147
+ block: ToolBlock;
148
+ turnId: string;
149
+ /** The block's place-4 state from the turn layout; `open` while it waits for this person. */
150
+ state: AiTurnActionState | "open";
151
+ phase?: AiTurnPhase;
152
+ }) {
153
+ const actions = useAiChatActions();
154
+ const timeZone = useReaderTimeZone();
155
+ const card = useToolActionCard(() => props.block);
156
+ const t = () => actionMessages(card().locale);
157
+ const [answer, setAnswer] = createSignal<LocalAnswer>(null);
158
+ const [lost, setLost] = createSignal(false);
159
+ const [elsewhere, setElsewhere] = createSignal(false);
160
+ const [checked, setChecked] = createSignal<AiActionCheck | null>(null);
161
+ const [checking, setChecking] = createSignal(false);
162
+ const [checkError, setCheckError] = createSignal(false);
163
+ // A new approval for the same block, such as the next website a code run asks for, starts a fresh question. `on`
164
+ // runs again whenever the block changes, so the call id is compared by hand.
165
+ let answeredCall = props.block.callId;
166
+ createEffect(
167
+ on(
168
+ () => props.block.callId,
169
+ (callId) => {
170
+ if (callId === answeredCall) return;
171
+ answeredCall = callId;
172
+ // Only a new question starts fresh; the block returning to its parent call after this answer keeps it.
173
+ if (props.block.status !== "awaiting_approval") return;
174
+ setAnswer(null);
175
+ setLost(false);
176
+ setElsewhere(false);
177
+ },
178
+ ),
179
+ );
180
+ // A question that leaves this window open without an answer here was decided somewhere else. (`on` with `defer`
181
+ // never passes the first value as the previous one, so this effect runs from the start.)
182
+ createEffect(
183
+ on(
184
+ () => props.block.status === "awaiting_approval",
185
+ (open, wasOpen) => {
186
+ if (wasOpen === true && !open && answer() === null) setElsewhere(true);
187
+ if (!open) setLost(false);
188
+ },
189
+ ),
190
+ );
191
+ const request = () => ({ turnId: props.turnId, callId: props.block.callId, name: props.block.name });
192
+ const submit = async (input: { approved: boolean; remember?: "chat" }) => {
193
+ if (answer() !== null || !actions.onApproval) return;
194
+ // A failure belongs to the question it answered: the next question of the same block may already show.
195
+ const submitted = props.block.callId;
196
+ setLost(false);
197
+ setAnswer(input.approved ? "approve" : "decline");
198
+ try {
199
+ await actions.onApproval(request(), input);
200
+ } catch {
201
+ if (props.block.callId === submitted) setLost(true);
202
+ }
203
+ };
204
+ const reopen = () => {
205
+ setLost(false);
206
+ setAnswer(null);
207
+ };
208
+ // The stored check wins once it is settled or newer than the one this card asked for, so another window's check
209
+ // or a reload is never hidden behind an older local answer.
210
+ const check = () => {
211
+ const local = checked();
212
+ const stored = props.block.check;
213
+ if (!local || !stored) return local ?? stored;
214
+ if (stored.state === "done" || stored.state === "not_done") return stored;
215
+ return (stored.at ?? "") > (local.at ?? "") ? stored : local;
216
+ };
217
+ // A stored claim of a running check expires at a known moment; past it the card offers to check again. The clock is
218
+ // read again whenever the claim changes, so a claim that arrives late expires on time too.
219
+ const [now, setNow] = createSignal(Date.now());
220
+ const claimAt = () => (check()?.state === "checking" ? check()?.at : undefined);
221
+ createEffect(
222
+ on(claimAt, (at) => {
223
+ setNow(Date.now());
224
+ if (!at) return;
225
+ const remaining = Date.parse(at) + AI_TOOL_CHECK_STALE_MS - Date.now();
226
+ if (remaining <= 0) return;
227
+ const timer = setTimeout(() => setNow(Date.now()), remaining + 50);
228
+ onCleanup(() => clearTimeout(timer));
229
+ }),
230
+ );
231
+ const claimAge = () => {
232
+ const at = claimAt();
233
+ return at ? now() - Date.parse(at) : null;
234
+ };
235
+ const flow = createMemo(() =>
236
+ flowOf({
237
+ block: props.block,
238
+ state: props.state,
239
+ answer: answer(),
240
+ lost: lost(),
241
+ check: check(),
242
+ phase: props.phase,
243
+ staleCheck: (claimAge() ?? 0) > AI_TOOL_CHECK_STALE_MS,
244
+ }),
245
+ );
246
+ const decisionState = (): ChatDecisionState => {
247
+ if (props.block.status === "awaiting_approval") return answer() === null && !lost() ? "open" : "sending";
248
+ return flow().flow === "expired" ? "expired" : "decided";
249
+ };
250
+ const time = () => (props.block.at ? dates.formatTime(props.block.at, { locale: card().locale, timeZone }) : undefined);
251
+ // A line that waits hidden in the cell reserves room for the time it will show, so it cannot wrap later. The widest
252
+ // time of the day stands in until the real one is known.
253
+ const reservedTime = () => time() ?? dates.formatTime("2000-01-01T23:58:00.000Z", { locale: card().locale, timeZone: "UTC" });
254
+ const receiptText = (): ActionText => {
255
+ const result = isRecord(props.block.result) ? props.block.result : null;
256
+ const receipt = CapabilityActionReceiptSchema.safeParse(result?.receipt);
257
+ return receipt.success ? actionReceiptText(receipt.data, card(), timeZone) : card().forms.done;
258
+ };
259
+ const checkAgain = async () => {
260
+ if (!actions.onCheckAction || checking()) return;
261
+ setChecking(true);
262
+ setCheckError(false);
263
+ try {
264
+ setChecked(await actions.onCheckAction(request()));
265
+ } catch {
266
+ setCheckError(true);
267
+ } finally {
268
+ setChecking(false);
269
+ }
270
+ };
271
+ const unknownActions = (withCheck: boolean): JSX.Element => (
272
+ <>
273
+ <Show when={card().open}>
274
+ {(href) => (
275
+ <ButtonLink href={href()} size="sm" variant="subtle">
276
+ <i class={card().app.icon} aria-hidden="true" />
277
+ {t().openIn({ app: card().app.name })}
278
+ </ButtonLink>
279
+ )}
280
+ </Show>
281
+ <Show when={withCheck && card().hasCheck && actions.onCheckAction}>
282
+ <Button size="sm" variant="subtle" loading={checking()} onClick={() => void checkAgain()}>
283
+ <i class="ti ti-refresh" aria-hidden="true" />
284
+ {t().checkAgain}
285
+ </Button>
286
+ </Show>
287
+ </>
288
+ );
289
+ const askAgain = () =>
290
+ actions.onContinueTurn
291
+ ? { label: t().askAgain, onSelect: () => actions.onContinueTurn?.(t().askAgainMessage({ sentence: card().short })) }
292
+ : undefined;
293
+ const lines = createMemo((): Record<string, ActionLine> => {
294
+ const current = flow();
295
+ const c = card();
296
+ const words = t();
297
+ switch (current.flow) {
298
+ case "run":
299
+ return {
300
+ running: { tone: "running", text: c.forms.running },
301
+ done: {
302
+ tone: "done",
303
+ text: receiptText(),
304
+ time: current.current === "done" ? time() : reservedTime(),
305
+ ...(elsewhere() ? { detail: words.approvedElsewhere } : {}),
306
+ ...(resultLinks(props.block).length > 0 ? { actions: <ResultLinks links={resultLinks(props.block)} align="start" /> } : {}),
307
+ },
308
+ };
309
+ case "rejected": {
310
+ const rejected: ActionLine = {
311
+ tone: "off",
312
+ text: c.forms.rejected,
313
+ detail: elsewhere() ? words.declinedElsewhere : words.declined,
314
+ time: time(),
315
+ };
316
+ // The time arrives with the server's answer; a hidden twin with room for it keeps the line from growing then.
317
+ return time() ? { rejected } : { rejected, rejectedTimed: { ...rejected, time: reservedTime() } };
318
+ }
319
+ case "expired":
320
+ return { expired: { tone: "off", icon: "ti ti-clock-x", text: c.forms.notRun, detail: words.expired, action: askAgain() } };
321
+ case "stopped":
322
+ return { stopped: { tone: "off", text: c.forms.notRun, detail: words.stopped } };
323
+ case "error":
324
+ return {
325
+ error: {
326
+ tone: "error",
327
+ text: c.forms.notRun,
328
+ detail: errorDetail(props.block.result) || aiChatMessages(c.locale).actionNotCompleted,
329
+ time: time(),
330
+ },
331
+ };
332
+ case "failed":
333
+ return {
334
+ failed: {
335
+ tone: "error",
336
+ text: [{ kind: "text", text: aiChatMessages(c.locale).receiptFailed({ title: c.short }) }],
337
+ detail: errorDetail(props.block.result) || aiChatMessages(c.locale).actionNotCompleted,
338
+ time: time(),
339
+ },
340
+ };
341
+ case "lost":
342
+ return {
343
+ lost: {
344
+ tone: "warning",
345
+ icon: "ti ti-alert-triangle",
346
+ text: [...splitFrame(words.lostAbout({ subject: "\u0000" }), c.subject)],
347
+ detail: words.lostDetail,
348
+ action: { label: words.answerAgain, onSelect: reopen },
349
+ },
350
+ };
351
+ case "check": {
352
+ const app = c.app.name;
353
+ const settled = check()?.receipt ? actionReceiptText(check()!.receipt!, c, timeZone) : c.forms.done;
354
+ const failedDetail = checkError() ? words.checkAgainFailed : undefined;
355
+ // Only the lines this check can still reach share the cell: a settled outcome stands alone, an open one keeps
356
+ // the room the next check needs.
357
+ const all: Record<string, ActionLine> = {
358
+ checking: { tone: "running", text: c.forms.unknown, detail: words.checking({ app }) },
359
+ checkedDone: { tone: "done", text: settled, detail: words.checkedDone({ app }), time: time() },
360
+ checkedNotDone: { tone: "off", text: c.forms.notRun, detail: words.checkedNotDone({ app }), time: time() },
361
+ pending: {
362
+ tone: "warning",
363
+ icon: "ti ti-help-circle",
364
+ text: c.forms.unknown,
365
+ detail: failedDetail ?? words.checkPending({ app }),
366
+ actions: unknownActions(true),
367
+ },
368
+ checkFailed: {
369
+ tone: "warning",
370
+ icon: "ti ti-help-circle",
371
+ text: c.forms.unknownStill,
372
+ detail: failedDetail ?? words.checkFailed({ app }),
373
+ actions: unknownActions(true),
374
+ },
375
+ // No check ran, such as when Core could not store its claim; the review's check, if any, is the person's.
376
+ noCheck: {
377
+ tone: "warning",
378
+ icon: "ti ti-help-circle",
379
+ text: c.forms.unknown,
380
+ detail: failedDetail ?? words.noCheck,
381
+ ...(c.open || c.hasCheck ? { actions: unknownActions(true) } : {}),
382
+ },
383
+ // A call the turn's end cut off has no check yet; the review's check, if any, is the person's to start.
384
+ cutOff: {
385
+ tone: "warning",
386
+ icon: "ti ti-help-circle",
387
+ text: c.forms.unknown,
388
+ detail: failedDetail ?? words.cutOff,
389
+ ...(c.open || c.hasCheck ? { actions: unknownActions(true) } : {}),
390
+ },
391
+ };
392
+ const reachable: Record<typeof current.current, (keyof typeof all)[]> = {
393
+ checking: ["checking", "checkedDone", "checkedNotDone", "pending", "checkFailed"],
394
+ pending: ["pending", "checkFailed", "checkedDone", "checkedNotDone"],
395
+ checkFailed: ["pending", "checkFailed", "checkedDone", "checkedNotDone"],
396
+ checkedDone: ["checkedDone"],
397
+ checkedNotDone: ["checkedNotDone"],
398
+ noCheck: ["noCheck", "checkedDone", "checkedNotDone", "pending", "checkFailed"],
399
+ cutOff: ["cutOff", "checkedDone", "checkedNotDone", "pending", "checkFailed"],
400
+ };
401
+ return Object.fromEntries(reachable[current.current].map((key) => [key, all[key]!]));
402
+ }
403
+ }
404
+ });
405
+ const currentKey = () => {
406
+ const current = flow();
407
+ return "current" in current ? current.current : current.flow;
408
+ };
409
+ const unavailable = () => {
410
+ if (!actions.onApproval) return actionMessages(card().locale).unavailable;
411
+ if (actions.actionDisabled?.()) return actionMessages(card().locale).stopping;
412
+ return undefined;
413
+ };
414
+ const remember = () => {
415
+ const approval = props.block.approval;
416
+ if (!approval?.allowChat) return undefined;
417
+ return {
418
+ label: approval.website ? t().rememberWebsite : t().remember,
419
+ onSelect: () => submit({ approved: true, remember: "chat" }),
420
+ };
421
+ };
422
+ return (
423
+ <ActionQuestion
424
+ card={card()}
425
+ state={decisionState()}
426
+ timeZone={timeZone}
427
+ anchorId={props.block.id}
428
+ unavailable={unavailable()}
429
+ remember={remember()}
430
+ onConfirm={() => submit({ approved: true })}
431
+ onDecline={() => submit({ approved: false })}
432
+ outcome={<ActionReceipt card={card()} lines={lines()} current={currentKey()} />}
433
+ />
434
+ );
435
+ }
436
+
437
+ /** Links a result offers, limited to paths inside this Cloud. */
438
+ export const resultLinks = (block: ToolBlock): Record<string, unknown>[] => {
439
+ const value = (isRecord(block.result) ? block.result : null)?.links;
440
+ return Array.isArray(value)
441
+ ? value.filter(
442
+ (link): link is Record<string, unknown> =>
443
+ isRecord(link) && typeof link.href === "string" && /^\/(?![\\/])[^\\\u0000-\u001f\u007f]*$/.test(link.href),
444
+ )
445
+ : [];
446
+ };
447
+
448
+ export function ResultLinks(props: { links: Record<string, unknown>[]; align?: "start" | "end" }) {
449
+ const locale = useLocale();
450
+ const t = () => aiChatMessages(locale());
451
+ const label = (link: Record<string, unknown>) =>
452
+ typeof link.title === "string" ? link.title : link.rel === "edit" ? t().edit : link.rel === "download" ? t().download : t().open;
453
+ return (
454
+ <span class={`flex min-w-0 flex-wrap items-center gap-1 ${props.align === "start" ? "justify-start" : "justify-end"}`}>
455
+ <For each={props.links}>
456
+ {(link) => (
457
+ <ButtonLink
458
+ class="ai-chat-result-link"
459
+ href={String(link.href)}
460
+ target="_blank"
461
+ rel="noopener noreferrer"
462
+ size="xs"
463
+ variant="ghost"
464
+ >
465
+ {label(link)}
466
+ </ButtonLink>
467
+ )}
468
+ </For>
469
+ </span>
470
+ );
471
+ }
472
+
473
+ /** Puts a sentence into a frame such as "Your answer about \u0000 didn't arrive." */
474
+ const splitFrame = (frame: string, inner: ActionText): ActionText => {
475
+ const [before = "", after = ""] = frame.split("\u0000");
476
+ return [
477
+ ...(before ? [{ kind: "text" as const, text: before }] : []),
478
+ ...inner,
479
+ ...(after ? [{ kind: "text" as const, text: after }] : []),
480
+ ];
481
+ };