@k2b/cloud 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -0,0 +1,456 @@
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 { CapabilityActionReceiptSchema } from "../../contracts/capabilities";
5
+ import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck } from "../action-review";
6
+ import type { AiTurnBlock } from "../protocol";
7
+ import { type ActionCard, type ActionText, actionReceiptText, toolActionCard } from "./action-card";
8
+ import { actionMessages } from "./action-messages";
9
+ import { type ActionLine, ActionQuestion, ActionReceipt } from "./action-question";
10
+ import { useReaderTimeZone } from "./action-sentences";
11
+ import { useAiChatActions } from "./message-actions";
12
+ import { capabilityErrorDescription, isRecord } from "./message-utils";
13
+ import { aiChatMessages } from "./messages";
14
+ import { legacyOutcomeWords } from "./tool-action-words";
15
+ import type { AiTurnActionState, AiTurnPhase } from "./turn-layout";
16
+
17
+ type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
18
+
19
+ /**
20
+ * Codes that say Cloud could not see whether the Action happened; nothing may repeat it blindly. Core rewrites a
21
+ * failure after it reached the app to ACTION_OUTCOME_UNKNOWN, so a bare transport code such as APP_UNAVAILABLE failed
22
+ * before the app saw anything, or comes from older history; either way it reads as a failure with its message.
23
+ */
24
+ const UNKNOWN_CODES = new Set(["ACTION_OUTCOME_UNKNOWN", "IDEMPOTENCY_UNCERTAIN", "IDEMPOTENCY_IN_PROGRESS"]);
25
+
26
+ /** Cloud's own transport codes: they say the call failed, not that the app refused it, so they never claim "not run". */
27
+ const TRANSPORT_CODES = new Set([
28
+ "DEADLINE_EXCEEDED",
29
+ "APP_UNAVAILABLE",
30
+ "REQUEST_CANCELLED",
31
+ "INVALID_APP_RESPONSE",
32
+ "RESPONSE_TOO_LARGE",
33
+ "INTERNAL",
34
+ ]);
35
+
36
+ /** The framework code of a failed call, from `CODE: message` or a structured error. */
37
+ export const toolErrorCode = (result: unknown): string | null => {
38
+ if (typeof result === "string") return /^([A-Z][A-Z0-9_]{2,}):/.exec(result.trim())?.[1] ?? null;
39
+ if (isRecord(result) && typeof result.code === "string") return result.code;
40
+ return null;
41
+ };
42
+
43
+ /** The message without its code, so the detail under a line reads as a sentence. */
44
+ const errorDetail = (result: unknown): string => capabilityErrorDescription(result).replace(/^[A-Z][A-Z0-9_]{2,}:\s*/, "");
45
+
46
+ /** The card of one tool block in the reader's locale and zone. */
47
+ export const useToolActionCard = (block: () => ToolBlock): (() => ActionCard) => {
48
+ const locale = useLocale();
49
+ const timeZone = useReaderTimeZone();
50
+ return createMemo(() =>
51
+ toolActionCard(block(), { locale: locale(), timeZone, outcome: legacyOutcomeWords(block(), locale(), timeZone) }),
52
+ );
53
+ };
54
+
55
+ type LocalAnswer = "approve" | "decline" | null;
56
+
57
+ /** Which outcome a call reached and what the person did here, as one key per flow. */
58
+ type Flow =
59
+ | { flow: "run"; current: "running" | "done" }
60
+ | { flow: "rejected" }
61
+ | { flow: "expired" }
62
+ | { flow: "stopped" }
63
+ | { flow: "error" }
64
+ | { flow: "failed" }
65
+ | { flow: "lost" }
66
+ | {
67
+ flow: "check";
68
+ current: "checking" | "checkedDone" | "checkedNotDone" | "pending" | "checkFailed" | "noCheck" | "cutOff";
69
+ };
70
+
71
+ const flowOf = (input: {
72
+ block: ToolBlock;
73
+ state: AiTurnActionState | "open";
74
+ answer: LocalAnswer;
75
+ lost: boolean;
76
+ check: AiActionCheck | undefined;
77
+ phase?: AiTurnPhase;
78
+ /** The stored claim of a running check is older than the stale limit. */
79
+ staleCheck?: boolean;
80
+ }): Flow => {
81
+ const { block, check } = input;
82
+ // A lost answer holds only while the question still waits; the server's own outcome always wins over it.
83
+ if (input.lost && block.status === "awaiting_approval") return { flow: "lost" };
84
+ if (block.status === "awaiting_approval") return input.answer === "decline" ? { flow: "rejected" } : { flow: "run", current: "running" };
85
+ // A check runs only inside a live turn and only for a while; one that history still shows as running, or whose
86
+ // claim went stale, was cut off and is open again.
87
+ if (check?.state === "checking")
88
+ return input.staleCheck || input.phase === "completed" || input.phase === "stopped" || input.phase === "failed"
89
+ ? { flow: "check", current: "pending" }
90
+ : { flow: "check", current: "checking" };
91
+ if (check?.state === "done") return { flow: "check", current: "checkedDone" };
92
+ if (check?.state === "not_done") return { flow: "check", current: "checkedNotDone" };
93
+ // An open check stays open whatever the call's own end looked like, such as a turn stopped while it checked.
94
+ if (check?.state === "pending") return { flow: "check", current: "pending" };
95
+ if (check?.state === "failed") return { flow: "check", current: "checkFailed" };
96
+ if (input.state === "rejected") return { flow: "rejected" };
97
+ // The person's stop ends an open question or a call it cut off; a question nobody answered expired. A call that was
98
+ // still running when the turn ended any other way may have reached the app, so its outcome is open.
99
+ if (input.state === "not_run") {
100
+ if (input.phase === "stopped") return { flow: "stopped" };
101
+ return block.approved === false ? { flow: "expired" } : { flow: "check", current: "cutOff" };
102
+ }
103
+ if (input.state === "failed") {
104
+ const code = toolErrorCode(block.result);
105
+ if (code === "ACTION_NOT_DONE") return { flow: "check", current: "checkedNotDone" };
106
+ if (code && UNKNOWN_CODES.has(code)) {
107
+ return { flow: "check", current: "noCheck" };
108
+ }
109
+ // A failure with an app's code was refused before anything happened; a transport code or none proves nothing.
110
+ return code && !TRANSPORT_CODES.has(code) ? { flow: "error" } : { flow: "failed" };
111
+ }
112
+ if (input.state === "done") return { flow: "run", current: "done" };
113
+ return { flow: "run", current: "running" };
114
+ };
115
+
116
+ /**
117
+ * One call in place 4: the open question, then, in the same element, its outcome line. An answer here collapses the
118
+ * question at once to the running or declined line and moves the focus there; the server's outcome replaces it in the
119
+ * same cell. A call that never asked shows only its outcome line.
120
+ */
121
+ export function ToolActionView(props: {
122
+ block: ToolBlock;
123
+ turnId: string;
124
+ /** The block's place-4 state from the turn layout; `open` while it waits for this person. */
125
+ state: AiTurnActionState | "open";
126
+ phase?: AiTurnPhase;
127
+ }) {
128
+ const actions = useAiChatActions();
129
+ const timeZone = useReaderTimeZone();
130
+ const card = useToolActionCard(() => props.block);
131
+ const t = () => actionMessages(card().locale);
132
+ const [answer, setAnswer] = createSignal<LocalAnswer>(null);
133
+ const [lost, setLost] = createSignal(false);
134
+ const [elsewhere, setElsewhere] = createSignal(false);
135
+ const [checked, setChecked] = createSignal<AiActionCheck | null>(null);
136
+ const [checking, setChecking] = createSignal(false);
137
+ const [checkError, setCheckError] = createSignal(false);
138
+ // A new approval for the same block, such as the next website a code run asks for, starts a fresh question. `on`
139
+ // runs again whenever the block changes, so the call id is compared by hand.
140
+ let answeredCall = props.block.callId;
141
+ createEffect(
142
+ on(
143
+ () => props.block.callId,
144
+ (callId) => {
145
+ if (callId === answeredCall) return;
146
+ answeredCall = callId;
147
+ // Only a new question starts fresh; the block returning to its parent call after this answer keeps it.
148
+ if (props.block.status !== "awaiting_approval") return;
149
+ setAnswer(null);
150
+ setLost(false);
151
+ setElsewhere(false);
152
+ },
153
+ ),
154
+ );
155
+ // A question that leaves this window open without an answer here was decided somewhere else. (`on` with `defer`
156
+ // never passes the first value as the previous one, so this effect runs from the start.)
157
+ createEffect(
158
+ on(
159
+ () => props.block.status === "awaiting_approval",
160
+ (open, wasOpen) => {
161
+ if (wasOpen === true && !open && answer() === null) setElsewhere(true);
162
+ if (!open) setLost(false);
163
+ },
164
+ ),
165
+ );
166
+ const request = () => ({ turnId: props.turnId, callId: props.block.callId, name: props.block.name });
167
+ const submit = async (input: { approved: boolean; remember?: "chat" }) => {
168
+ if (answer() !== null || !actions.onApproval) return;
169
+ // A failure belongs to the question it answered: the next question of the same block may already show.
170
+ const submitted = props.block.callId;
171
+ setLost(false);
172
+ setAnswer(input.approved ? "approve" : "decline");
173
+ try {
174
+ await actions.onApproval(request(), input);
175
+ } catch {
176
+ if (props.block.callId === submitted) setLost(true);
177
+ }
178
+ };
179
+ const reopen = () => {
180
+ setLost(false);
181
+ setAnswer(null);
182
+ };
183
+ // The stored check wins once it is settled or newer than the one this card asked for, so another window's check
184
+ // or a reload is never hidden behind an older local answer.
185
+ const check = () => {
186
+ const local = checked();
187
+ const stored = props.block.check;
188
+ if (!local || !stored) return local ?? stored;
189
+ if (stored.state === "done" || stored.state === "not_done") return stored;
190
+ return (stored.at ?? "") > (local.at ?? "") ? stored : local;
191
+ };
192
+ // A stored claim of a running check expires at a known moment; past it the card offers to check again. The clock is
193
+ // read again whenever the claim changes, so a claim that arrives late expires on time too.
194
+ const [now, setNow] = createSignal(Date.now());
195
+ const claimAt = () => (check()?.state === "checking" ? check()?.at : undefined);
196
+ createEffect(
197
+ on(claimAt, (at) => {
198
+ setNow(Date.now());
199
+ if (!at) return;
200
+ const remaining = Date.parse(at) + AI_TOOL_CHECK_STALE_MS - Date.now();
201
+ if (remaining <= 0) return;
202
+ const timer = setTimeout(() => setNow(Date.now()), remaining + 50);
203
+ onCleanup(() => clearTimeout(timer));
204
+ }),
205
+ );
206
+ const claimAge = () => {
207
+ const at = claimAt();
208
+ return at ? now() - Date.parse(at) : null;
209
+ };
210
+ const flow = createMemo(() =>
211
+ flowOf({
212
+ block: props.block,
213
+ state: props.state,
214
+ answer: answer(),
215
+ lost: lost(),
216
+ check: check(),
217
+ phase: props.phase,
218
+ staleCheck: (claimAge() ?? 0) > AI_TOOL_CHECK_STALE_MS,
219
+ }),
220
+ );
221
+ const decisionState = (): ChatDecisionState => {
222
+ if (props.block.status === "awaiting_approval") return answer() === null && !lost() ? "open" : "sending";
223
+ return flow().flow === "expired" ? "expired" : "decided";
224
+ };
225
+ const time = () => (props.block.at ? dates.formatTime(props.block.at, { locale: card().locale, timeZone }) : undefined);
226
+ // A line that waits hidden in the cell reserves room for the time it will show, so it cannot wrap later. The widest
227
+ // time of the day stands in until the real one is known.
228
+ const reservedTime = () => time() ?? dates.formatTime("2000-01-01T23:58:00.000Z", { locale: card().locale, timeZone: "UTC" });
229
+ const receiptText = (): ActionText => {
230
+ const result = isRecord(props.block.result) ? props.block.result : null;
231
+ const receipt = CapabilityActionReceiptSchema.safeParse(result?.receipt);
232
+ return receipt.success ? actionReceiptText(receipt.data, card(), timeZone) : card().forms.done;
233
+ };
234
+ const checkAgain = async () => {
235
+ if (!actions.onCheckAction || checking()) return;
236
+ setChecking(true);
237
+ setCheckError(false);
238
+ try {
239
+ setChecked(await actions.onCheckAction(request()));
240
+ } catch {
241
+ setCheckError(true);
242
+ } finally {
243
+ setChecking(false);
244
+ }
245
+ };
246
+ const unknownActions = (withCheck: boolean): JSX.Element => (
247
+ <>
248
+ <Show when={card().open}>
249
+ {(href) => (
250
+ <ButtonLink href={href()} size="sm" variant="subtle">
251
+ <i class={card().app.icon} aria-hidden="true" />
252
+ {t().openIn({ app: card().app.name })}
253
+ </ButtonLink>
254
+ )}
255
+ </Show>
256
+ <Show when={withCheck && card().hasCheck && actions.onCheckAction}>
257
+ <Button size="sm" variant="subtle" loading={checking()} onClick={() => void checkAgain()}>
258
+ <i class="ti ti-refresh" aria-hidden="true" />
259
+ {t().checkAgain}
260
+ </Button>
261
+ </Show>
262
+ </>
263
+ );
264
+ const askAgain = () =>
265
+ actions.onContinueTurn
266
+ ? { label: t().askAgain, onSelect: () => actions.onContinueTurn?.(t().askAgainMessage({ sentence: card().short })) }
267
+ : undefined;
268
+ const lines = createMemo((): Record<string, ActionLine> => {
269
+ const current = flow();
270
+ const c = card();
271
+ const words = t();
272
+ switch (current.flow) {
273
+ case "run":
274
+ return {
275
+ running: { tone: "running", text: c.forms.running },
276
+ done: {
277
+ tone: "done",
278
+ text: receiptText(),
279
+ time: current.current === "done" ? time() : reservedTime(),
280
+ ...(elsewhere() ? { detail: words.approvedElsewhere } : {}),
281
+ ...(resultLinks(props.block).length > 0 ? { actions: <ResultLinks links={resultLinks(props.block)} align="start" /> } : {}),
282
+ },
283
+ };
284
+ case "rejected": {
285
+ const rejected: ActionLine = {
286
+ tone: "off",
287
+ text: c.forms.rejected,
288
+ detail: elsewhere() ? words.declinedElsewhere : words.declined,
289
+ time: time(),
290
+ };
291
+ // The time arrives with the server's answer; a hidden twin with room for it keeps the line from growing then.
292
+ return time() ? { rejected } : { rejected, rejectedTimed: { ...rejected, time: reservedTime() } };
293
+ }
294
+ case "expired":
295
+ return { expired: { tone: "off", icon: "ti ti-clock-x", text: c.forms.notRun, detail: words.expired, action: askAgain() } };
296
+ case "stopped":
297
+ return { stopped: { tone: "off", text: c.forms.notRun, detail: words.stopped } };
298
+ case "error":
299
+ return {
300
+ error: {
301
+ tone: "error",
302
+ text: c.forms.notRun,
303
+ detail: errorDetail(props.block.result) || aiChatMessages(c.locale).actionNotCompleted,
304
+ time: time(),
305
+ },
306
+ };
307
+ case "failed":
308
+ return {
309
+ failed: {
310
+ tone: "error",
311
+ text: [{ kind: "text", text: aiChatMessages(c.locale).receiptFailed({ title: c.short }) }],
312
+ detail: errorDetail(props.block.result) || aiChatMessages(c.locale).actionNotCompleted,
313
+ time: time(),
314
+ },
315
+ };
316
+ case "lost":
317
+ return {
318
+ lost: {
319
+ tone: "warning",
320
+ icon: "ti ti-alert-triangle",
321
+ text: [...splitFrame(words.lostAbout({ subject: "\u0000" }), c.subject)],
322
+ detail: words.lostDetail,
323
+ action: { label: words.answerAgain, onSelect: reopen },
324
+ },
325
+ };
326
+ case "check": {
327
+ const app = c.app.name;
328
+ const settled = check()?.receipt ? actionReceiptText(check()!.receipt!, c, timeZone) : c.forms.done;
329
+ const failedDetail = checkError() ? words.checkAgainFailed : undefined;
330
+ // Only the lines this check can still reach share the cell: a settled outcome stands alone, an open one keeps
331
+ // the room the next check needs.
332
+ const all: Record<string, ActionLine> = {
333
+ checking: { tone: "running", text: c.forms.unknown, detail: words.checking({ app }) },
334
+ checkedDone: { tone: "done", text: settled, detail: words.checkedDone({ app }), time: time() },
335
+ checkedNotDone: { tone: "off", text: c.forms.notRun, detail: words.checkedNotDone({ app }), time: time() },
336
+ pending: {
337
+ tone: "warning",
338
+ icon: "ti ti-help-circle",
339
+ text: c.forms.unknown,
340
+ detail: failedDetail ?? words.checkPending({ app }),
341
+ actions: unknownActions(true),
342
+ },
343
+ checkFailed: {
344
+ tone: "warning",
345
+ icon: "ti ti-help-circle",
346
+ text: c.forms.unknownStill,
347
+ detail: failedDetail ?? words.checkFailed({ app }),
348
+ actions: unknownActions(true),
349
+ },
350
+ // No check ran, such as when Core could not store its claim; the review's check, if any, is the person's.
351
+ noCheck: {
352
+ tone: "warning",
353
+ icon: "ti ti-help-circle",
354
+ text: c.forms.unknown,
355
+ detail: failedDetail ?? words.noCheck,
356
+ ...(c.open || c.hasCheck ? { actions: unknownActions(true) } : {}),
357
+ },
358
+ // A call the turn's end cut off has no check yet; the review's check, if any, is the person's to start.
359
+ cutOff: {
360
+ tone: "warning",
361
+ icon: "ti ti-help-circle",
362
+ text: c.forms.unknown,
363
+ detail: failedDetail ?? words.cutOff,
364
+ ...(c.open || c.hasCheck ? { actions: unknownActions(true) } : {}),
365
+ },
366
+ };
367
+ const reachable: Record<typeof current.current, (keyof typeof all)[]> = {
368
+ checking: ["checking", "checkedDone", "checkedNotDone", "pending", "checkFailed"],
369
+ pending: ["pending", "checkFailed", "checkedDone", "checkedNotDone"],
370
+ checkFailed: ["pending", "checkFailed", "checkedDone", "checkedNotDone"],
371
+ checkedDone: ["checkedDone"],
372
+ checkedNotDone: ["checkedNotDone"],
373
+ noCheck: ["noCheck", "checkedDone", "checkedNotDone", "pending", "checkFailed"],
374
+ cutOff: ["cutOff", "checkedDone", "checkedNotDone", "pending", "checkFailed"],
375
+ };
376
+ return Object.fromEntries(reachable[current.current].map((key) => [key, all[key]!]));
377
+ }
378
+ }
379
+ });
380
+ const currentKey = () => {
381
+ const current = flow();
382
+ return "current" in current ? current.current : current.flow;
383
+ };
384
+ const unavailable = () => {
385
+ if (!actions.onApproval) return actionMessages(card().locale).unavailable;
386
+ if (actions.actionDisabled?.()) return actionMessages(card().locale).stopping;
387
+ return undefined;
388
+ };
389
+ const remember = () => {
390
+ const approval = props.block.approval;
391
+ if (!approval?.allowChat) return undefined;
392
+ return {
393
+ label: approval.website ? t().rememberWebsite : t().remember,
394
+ onSelect: () => submit({ approved: true, remember: "chat" }),
395
+ };
396
+ };
397
+ return (
398
+ <ActionQuestion
399
+ card={card()}
400
+ state={decisionState()}
401
+ timeZone={timeZone}
402
+ anchorId={props.block.id}
403
+ unavailable={unavailable()}
404
+ remember={remember()}
405
+ onConfirm={() => submit({ approved: true })}
406
+ onDecline={() => submit({ approved: false })}
407
+ outcome={<ActionReceipt card={card()} lines={lines()} current={currentKey()} />}
408
+ />
409
+ );
410
+ }
411
+
412
+ /** Links a result offers, limited to paths inside this Cloud. */
413
+ export const resultLinks = (block: ToolBlock): Record<string, unknown>[] => {
414
+ const value = (isRecord(block.result) ? block.result : null)?.links;
415
+ return Array.isArray(value)
416
+ ? value.filter(
417
+ (link): link is Record<string, unknown> =>
418
+ isRecord(link) && typeof link.href === "string" && /^\/(?![\\/])[^\\\u0000-\u001f\u007f]*$/.test(link.href),
419
+ )
420
+ : [];
421
+ };
422
+
423
+ export function ResultLinks(props: { links: Record<string, unknown>[]; align?: "start" | "end" }) {
424
+ const locale = useLocale();
425
+ const t = () => aiChatMessages(locale());
426
+ const label = (link: Record<string, unknown>) =>
427
+ typeof link.title === "string" ? link.title : link.rel === "edit" ? t().edit : link.rel === "download" ? t().download : t().open;
428
+ return (
429
+ <span class={`flex min-w-0 flex-wrap items-center gap-1 ${props.align === "start" ? "justify-start" : "justify-end"}`}>
430
+ <For each={props.links}>
431
+ {(link) => (
432
+ <ButtonLink
433
+ class="ai-chat-result-link"
434
+ href={String(link.href)}
435
+ target="_blank"
436
+ rel="noopener noreferrer"
437
+ size="xs"
438
+ variant="ghost"
439
+ >
440
+ {label(link)}
441
+ </ButtonLink>
442
+ )}
443
+ </For>
444
+ </span>
445
+ );
446
+ }
447
+
448
+ /** Puts a sentence into a frame such as "Your answer about \u0000 didn't arrive." */
449
+ const splitFrame = (frame: string, inner: ActionText): ActionText => {
450
+ const [before = "", after = ""] = frame.split("\u0000");
451
+ return [
452
+ ...(before ? [{ kind: "text" as const, text: before }] : []),
453
+ ...inner,
454
+ ...(after ? [{ kind: "text" as const, text: after }] : []),
455
+ ];
456
+ };
@@ -1,5 +1,4 @@
1
- import { dates } from "@k2b/stdlib";
2
- import { announce, Button, ButtonLink, Chat, NoticeCard, useLocale } from "@k2b/ui";
1
+ import { announce, Button, Chat, NoticeCard, useLocale } from "@k2b/ui";
3
2
  import {
4
3
  type Accessor,
5
4
  createEffect,
@@ -19,34 +18,20 @@ import { markdown } from "../../shared";
19
18
  import { type ChatTaskReason, chatTaskReasonSentence, chatTaskReasonState, chatTaskStopSentence } from "../chat-task-reasons";
20
19
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
21
20
  import type { AiTurnError } from "../types";
22
- import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
23
- import {
24
- type AiApprovalDecision,
25
- ApprovalBlockView,
26
- CompactionBlockView,
27
- CompactToolRow,
28
- SurveyToolView,
29
- TextEditorToolView,
30
- } from "./blocks";
21
+ import { toolSubject, useReaderTimeZone } from "./action-sentences";
22
+ import { CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
31
23
  import { CapabilityTablePreview } from "./capability-table";
32
24
  import { CloudChartBlock } from "./chart-block";
33
25
  import { PresentToolBlock } from "./file-tools";
34
26
  import { useAiChatActions } from "./message-actions";
35
- import {
36
- aiToolIcon,
37
- capabilityErrorDescription,
38
- displayToolName,
39
- isCardToolName,
40
- isChartToolName,
41
- isRecord,
42
- isSurveyToolName,
43
- } from "./message-utils";
27
+ import { aiToolIcon, displayToolName, isCardToolName, isChartToolName, isRecord, isSurveyToolName } from "./message-utils";
44
28
  import { aiChatMessages } from "./messages";
45
29
  import { AssistantMarkdownBlock } from "./primitives";
30
+ import { ResultLinks, resultLinks, ToolActionView } from "./tool-action";
46
31
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
47
32
  import { type AiWorkEntry, type AiWorkItem, countWorkSteps, groupWorkBlocks } from "./tool-groups";
48
33
  import { aiTurnErrorCanContinue, aiTurnErrorDescription } from "./turn-error";
49
- import type { AiTurnAction, AiTurnActionState, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
34
+ import type { AiTurnAction, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
50
35
  import { CloudCardBlock } from "./visual-tools";
51
36
 
52
37
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -427,42 +412,6 @@ const resultSummary = (block: ToolBlock): string => {
427
412
  return typeof value === "string" && value.trim().length <= 500 ? value.trim() : "";
428
413
  };
429
414
 
430
- /** Links a result offers, limited to paths inside this Cloud. */
431
- const resultLinks = (block: ToolBlock): Record<string, unknown>[] => {
432
- const value = resultRecord(block)?.links;
433
- return Array.isArray(value)
434
- ? value.filter(
435
- (link): link is Record<string, unknown> =>
436
- isRecord(link) && typeof link.href === "string" && /^\/(?![\\/])[^\\\u0000-\u001f\u007f]*$/.test(link.href),
437
- )
438
- : [];
439
- };
440
-
441
- function ResultLinks(props: { links: Record<string, unknown>[] }) {
442
- const locale = useLocale();
443
- const t = () => aiChatMessages(locale());
444
- const label = (link: Record<string, unknown>) =>
445
- typeof link.title === "string" ? link.title : link.rel === "edit" ? t().edit : link.rel === "download" ? t().download : t().open;
446
- return (
447
- <span class="flex min-w-0 flex-wrap items-center justify-end gap-1">
448
- <For each={props.links}>
449
- {(link) => (
450
- <ButtonLink
451
- class="ai-chat-result-link"
452
- href={String(link.href)}
453
- target="_blank"
454
- rel="noopener noreferrer"
455
- size="xs"
456
- variant="ghost"
457
- >
458
- {label(link)}
459
- </ButtonLink>
460
- )}
461
- </For>
462
- </span>
463
- );
464
- }
465
-
466
415
  /** The summary and links of a table result, unless its receipt in place 4 already shows them. */
467
416
  function CapabilityResultRow(props: { block: ToolBlock }) {
468
417
  const locale = useLocale();
@@ -526,87 +475,11 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
526
475
  );
527
476
  }
528
477
 
529
- type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
530
-
531
- /**
532
- * One line for an action in place 4, the same element while it runs and once it is done, rejected, failed, or not run.
533
- * It says what happened in the owning app's words: its sentence for the outcome, such as "Moved report.pdf to the
534
- * trash", else, once done, the application's own summary of the call. Without either it puts the state in the reader's
535
- * language before what the call does, such as "Rejected: Write report.md", or before its title and target.
536
- */
537
- function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
538
- const locale = useLocale();
539
- const timeZone = useReaderTimeZone();
540
- const t = () => aiChatMessages(locale());
541
- const block = () => props.block;
542
- const subject = () => {
543
- const sentence = toolSubject(block(), locale(), timeZone);
544
- if (sentence) return sentence;
545
- const title = block().presentation?.title ?? displayToolName(block().name, locale());
546
- const target = toolTarget(block());
547
- return target ? `${title} · ${target}` : title;
548
- };
549
- const capabilityAction = () => block().presentation?.kind === "capability";
550
- const links = () => (props.state === "done" ? resultLinks(block()) : []);
551
- const outcomeLabel = () => {
552
- const state = props.state;
553
- if (state === "running") return t().receiptRunning({ title: subject() });
554
- if (state === "done") {
555
- // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
556
- if (!capabilityAction()) return t().receiptApproved({ title: subject() });
557
- return toolOutcome(block(), "done", locale(), timeZone) || resultSummary(block()) || t().receiptDone({ title: subject() });
558
- }
559
- if (state === "rejected") return toolOutcome(block(), "rejected", locale(), timeZone) ?? t().receiptRejected({ title: subject() });
560
- if (state === "failed") return t().receiptFailed({ title: subject() });
561
- const notRun = toolOutcome(block(), "notRun", locale(), timeZone);
562
- return notRun
563
- ? t().receiptNotRunSentence({ sentence: notRun, stopped: props.stopped })
564
- : t().receiptNotRun({ title: subject(), stopped: props.stopped });
565
- };
566
- const declined = () => props.state === "rejected" || props.state === "not_run";
567
- const label = () => {
568
- const at = block().at;
569
- return at && props.state !== "running" ? `${outcomeLabel()} · ${dates.formatTime(at, { locale: locale(), timeZone })}` : outcomeLabel();
570
- };
571
- return (
572
- <Chat.Activity
573
- class="ai-turn-receipt"
574
- icon={declined() ? "ti ti-circle-off" : aiToolIcon(block().name, block().presentation?.appIcon)}
575
- accent={declined() ? undefined : block().presentation?.appAccent}
576
- label={label()}
577
- busy={props.state === "running"}
578
- description={props.state === "failed" ? capabilityErrorDescription(block().result) : undefined}
579
- trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
580
- />
581
- );
582
- }
583
-
584
478
  function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; turnId: string; phase: AiTurnPhase }) {
585
479
  const locale = useLocale();
586
480
  const timeZone = useReaderTimeZone();
587
- let row!: HTMLDivElement;
588
- // Set while focus is in this place, including after the focused control disappeared with the decided card.
589
- let focusedHere = false;
590
- // The decision the server accepted for a card here; its receipt replaces the card before the turn reports it. It
591
- // belongs to the call it answered: code that asks again shows the next request on the same block as a new card.
592
- const [decided, setDecided] = createSignal<AiApprovalDecision | null>(null);
593
481
  const action = () => props.action();
594
482
  const state = () => action()?.state;
595
- const decision = () => {
596
- const current = action();
597
- const value = decided();
598
- return current && value?.callId === current.block.callId ? value.approved : null;
599
- };
600
- const card = () => {
601
- const current = action();
602
- return current?.state === "open" && current.block.status === "awaiting_approval" && decision() === null ? current.block : undefined;
603
- };
604
- const receipt = (): ReceiptState | undefined => {
605
- const current = action();
606
- if (!current || card()) return undefined;
607
- if (current.state === "open" && current.block.status === "awaiting_approval") return decision() ? "running" : "rejected";
608
- return current.state === "open" || current.state === "interaction" ? undefined : current.state;
609
- };
610
483
  // Screen readers hear a waiting approval once, as one short line instead of the whole card, without moving focus.
611
484
  createEffect(
612
485
  on(state, (next, previous) => {
@@ -622,37 +495,14 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
622
495
  );
623
496
  }),
624
497
  );
625
- // A card the user decided becomes its receipt in place, once, as the direct result of the decision. Focus stays on
626
- // that place without scrolling.
627
- createEffect(
628
- on(
629
- () => card() !== undefined,
630
- (open, wasOpen) => {
631
- if (!wasOpen || open || !focusedHere || typeof document === "undefined") return;
632
- const focused = document.activeElement;
633
- if (!focused || focused === document.body || row.contains(focused)) row.focus({ preventScroll: true });
634
- },
635
- ),
636
- );
637
498
  return (
638
- <div
639
- ref={row}
640
- class="ai-turn__action"
641
- aria-live="off"
642
- tabIndex={-1}
643
- onFocusIn={() => {
644
- focusedHere = true;
645
- }}
646
- onFocusOut={(event) => {
647
- if (event.relatedTarget instanceof Node && !row.contains(event.relatedTarget)) focusedHere = false;
648
- }}
649
- >
499
+ <div class="ai-turn__action" aria-live="off">
650
500
  <Show when={action()}>
651
501
  {(current) => (
652
502
  <Switch>
653
- <Match when={card()}>{(block) => <ApprovalBlockView turnId={props.turnId} block={block()} onDecided={setDecided} />}</Match>
654
- <Match when={receipt()}>
655
- {(receiptState) => <AiReceipt block={current().block} state={receiptState()} stopped={props.phase === "stopped"} />}
503
+ {/* A question and every outcome of its call share one element, so an answer collapses it in place. */}
504
+ <Match when={current().state !== "interaction"}>
505
+ <ToolActionView block={current().block} turnId={props.turnId} state={current().state} phase={props.phase} />
656
506
  </Match>
657
507
  <Match when={true}>
658
508
  <Switch