@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,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,
@@ -16,36 +15,23 @@ import {
16
15
  untrack,
17
16
  } from "solid-js";
18
17
  import { markdown } from "../../shared";
18
+ import { type ChatTaskReason, chatTaskReasonSentence, chatTaskReasonState, chatTaskStopSentence } from "../chat-task-reasons";
19
19
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
20
20
  import type { AiTurnError } from "../types";
21
- import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
22
- import {
23
- type AiApprovalDecision,
24
- ApprovalBlockView,
25
- CompactionBlockView,
26
- CompactToolRow,
27
- SurveyToolView,
28
- TextEditorToolView,
29
- } from "./blocks";
21
+ import { toolSubject, useReaderTimeZone } from "./action-sentences";
22
+ import { CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
30
23
  import { CapabilityTablePreview } from "./capability-table";
31
24
  import { CloudChartBlock } from "./chart-block";
32
25
  import { PresentToolBlock } from "./file-tools";
33
26
  import { useAiChatActions } from "./message-actions";
34
- import {
35
- aiToolIcon,
36
- capabilityErrorDescription,
37
- displayToolName,
38
- isCardToolName,
39
- isChartToolName,
40
- isRecord,
41
- isSurveyToolName,
42
- } from "./message-utils";
27
+ import { aiToolIcon, displayToolName, isCardToolName, isChartToolName, isRecord, isSurveyToolName } from "./message-utils";
43
28
  import { aiChatMessages } from "./messages";
44
29
  import { AssistantMarkdownBlock } from "./primitives";
30
+ import { ResultLinks, resultLinks, ToolActionView } from "./tool-action";
45
31
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
46
32
  import { type AiWorkEntry, type AiWorkItem, countWorkSteps, groupWorkBlocks } from "./tool-groups";
47
33
  import { aiTurnErrorCanContinue, aiTurnErrorDescription } from "./turn-error";
48
- import type { AiTurnAction, AiTurnActionState, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
34
+ import type { AiTurnAction, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
49
35
  import { CloudCardBlock } from "./visual-tools";
50
36
 
51
37
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -65,8 +51,17 @@ export type AiTurnSegment = {
65
51
  earlier: boolean;
66
52
  /** Read reactively, so a ticking clock does not rebuild the segment. */
67
53
  duration: () => AiTurnDuration | null;
68
- /** Shown above the work of a delivered scheduled result. */
69
- scheduledTask?: { taskId: string; occurrenceId: string } | null;
54
+ /**
55
+ * Shown above the work of a delivered scheduled result. `stop` and `reason` add a system line the chat words in
56
+ * the reader's language; with `stop` or `reasonOnly` that line replaces the stored text.
57
+ */
58
+ scheduledTask?: {
59
+ taskId: string;
60
+ occurrenceId: string;
61
+ reason?: ChatTaskReason;
62
+ reasonOnly?: true;
63
+ stop?: { cause: "paused" | "deleted"; done: string[] };
64
+ } | null;
70
65
  /**
71
66
  * The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
72
67
  * reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
@@ -417,42 +412,6 @@ const resultSummary = (block: ToolBlock): string => {
417
412
  return typeof value === "string" && value.trim().length <= 500 ? value.trim() : "";
418
413
  };
419
414
 
420
- /** Links a result offers, limited to paths inside this Cloud. */
421
- const resultLinks = (block: ToolBlock): Record<string, unknown>[] => {
422
- const value = resultRecord(block)?.links;
423
- return Array.isArray(value)
424
- ? value.filter(
425
- (link): link is Record<string, unknown> =>
426
- isRecord(link) && typeof link.href === "string" && /^\/(?![\\/])[^\\\u0000-\u001f\u007f]*$/.test(link.href),
427
- )
428
- : [];
429
- };
430
-
431
- function ResultLinks(props: { links: Record<string, unknown>[] }) {
432
- const locale = useLocale();
433
- const t = () => aiChatMessages(locale());
434
- const label = (link: Record<string, unknown>) =>
435
- typeof link.title === "string" ? link.title : link.rel === "edit" ? t().edit : link.rel === "download" ? t().download : t().open;
436
- return (
437
- <span class="flex min-w-0 flex-wrap items-center justify-end gap-1">
438
- <For each={props.links}>
439
- {(link) => (
440
- <ButtonLink
441
- class="ai-chat-result-link"
442
- href={String(link.href)}
443
- target="_blank"
444
- rel="noopener noreferrer"
445
- size="xs"
446
- variant="ghost"
447
- >
448
- {label(link)}
449
- </ButtonLink>
450
- )}
451
- </For>
452
- </span>
453
- );
454
- }
455
-
456
415
  /** The summary and links of a table result, unless its receipt in place 4 already shows them. */
457
416
  function CapabilityResultRow(props: { block: ToolBlock }) {
458
417
  const locale = useLocale();
@@ -516,87 +475,11 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
516
475
  );
517
476
  }
518
477
 
519
- type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
520
-
521
- /**
522
- * One line for an action in place 4, the same element while it runs and once it is done, rejected, failed, or not run.
523
- * It says what happened in the owning app's words: its sentence for the outcome, such as "Moved report.pdf to the
524
- * trash", else, once done, the application's own summary of the call. Without either it puts the state in the reader's
525
- * language before what the call does, such as "Rejected: Write report.md", or before its title and target.
526
- */
527
- function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
528
- const locale = useLocale();
529
- const timeZone = useReaderTimeZone();
530
- const t = () => aiChatMessages(locale());
531
- const block = () => props.block;
532
- const subject = () => {
533
- const sentence = toolSubject(block(), locale(), timeZone);
534
- if (sentence) return sentence;
535
- const title = block().presentation?.title ?? displayToolName(block().name, locale());
536
- const target = toolTarget(block());
537
- return target ? `${title} · ${target}` : title;
538
- };
539
- const capabilityAction = () => block().presentation?.kind === "capability";
540
- const links = () => (props.state === "done" ? resultLinks(block()) : []);
541
- const outcomeLabel = () => {
542
- const state = props.state;
543
- if (state === "running") return t().receiptRunning({ title: subject() });
544
- if (state === "done") {
545
- // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
546
- if (!capabilityAction()) return t().receiptApproved({ title: subject() });
547
- return toolOutcome(block(), "done", locale(), timeZone) || resultSummary(block()) || t().receiptDone({ title: subject() });
548
- }
549
- if (state === "rejected") return toolOutcome(block(), "rejected", locale(), timeZone) ?? t().receiptRejected({ title: subject() });
550
- if (state === "failed") return t().receiptFailed({ title: subject() });
551
- const notRun = toolOutcome(block(), "notRun", locale(), timeZone);
552
- return notRun
553
- ? t().receiptNotRunSentence({ sentence: notRun, stopped: props.stopped })
554
- : t().receiptNotRun({ title: subject(), stopped: props.stopped });
555
- };
556
- const declined = () => props.state === "rejected" || props.state === "not_run";
557
- const label = () => {
558
- const at = block().at;
559
- return at && props.state !== "running" ? `${outcomeLabel()} · ${dates.formatTime(at, { locale: locale(), timeZone })}` : outcomeLabel();
560
- };
561
- return (
562
- <Chat.Activity
563
- class="ai-turn-receipt"
564
- icon={declined() ? "ti ti-circle-off" : aiToolIcon(block().name, block().presentation?.appIcon)}
565
- accent={declined() ? undefined : block().presentation?.appAccent}
566
- label={label()}
567
- busy={props.state === "running"}
568
- description={props.state === "failed" ? capabilityErrorDescription(block().result) : undefined}
569
- trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
570
- />
571
- );
572
- }
573
-
574
478
  function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; turnId: string; phase: AiTurnPhase }) {
575
479
  const locale = useLocale();
576
480
  const timeZone = useReaderTimeZone();
577
- let row!: HTMLDivElement;
578
- // Set while focus is in this place, including after the focused control disappeared with the decided card.
579
- let focusedHere = false;
580
- // The decision the server accepted for a card here; its receipt replaces the card before the turn reports it. It
581
- // belongs to the call it answered: code that asks again shows the next request on the same block as a new card.
582
- const [decided, setDecided] = createSignal<AiApprovalDecision | null>(null);
583
481
  const action = () => props.action();
584
482
  const state = () => action()?.state;
585
- const decision = () => {
586
- const current = action();
587
- const value = decided();
588
- return current && value?.callId === current.block.callId ? value.approved : null;
589
- };
590
- const card = () => {
591
- const current = action();
592
- return current?.state === "open" && current.block.status === "awaiting_approval" && decision() === null ? current.block : undefined;
593
- };
594
- const receipt = (): ReceiptState | undefined => {
595
- const current = action();
596
- if (!current || card()) return undefined;
597
- if (current.state === "open" && current.block.status === "awaiting_approval") return decision() ? "running" : "rejected";
598
- return current.state === "open" || current.state === "interaction" ? undefined : current.state;
599
- };
600
483
  // Screen readers hear a waiting approval once, as one short line instead of the whole card, without moving focus.
601
484
  createEffect(
602
485
  on(state, (next, previous) => {
@@ -612,37 +495,14 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
612
495
  );
613
496
  }),
614
497
  );
615
- // A card the user decided becomes its receipt in place, once, as the direct result of the decision. Focus stays on
616
- // that place without scrolling.
617
- createEffect(
618
- on(
619
- () => card() !== undefined,
620
- (open, wasOpen) => {
621
- if (!wasOpen || open || !focusedHere || typeof document === "undefined") return;
622
- const focused = document.activeElement;
623
- if (!focused || focused === document.body || row.contains(focused)) row.focus({ preventScroll: true });
624
- },
625
- ),
626
- );
627
498
  return (
628
- <div
629
- ref={row}
630
- class="ai-turn__action"
631
- aria-live="off"
632
- tabIndex={-1}
633
- onFocusIn={() => {
634
- focusedHere = true;
635
- }}
636
- onFocusOut={(event) => {
637
- if (event.relatedTarget instanceof Node && !row.contains(event.relatedTarget)) focusedHere = false;
638
- }}
639
- >
499
+ <div class="ai-turn__action" aria-live="off">
640
500
  <Show when={action()}>
641
501
  {(current) => (
642
502
  <Switch>
643
- <Match when={card()}>{(block) => <ApprovalBlockView turnId={props.turnId} block={block()} onDecided={setDecided} />}</Match>
644
- <Match when={receipt()}>
645
- {(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} />
646
506
  </Match>
647
507
  <Match when={true}>
648
508
  <Switch
@@ -789,6 +649,7 @@ function AiTurnText(props: {
789
649
  */
790
650
  export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosureState: AiToolDisclosureState }): JSX.Element {
791
651
  const locale = useLocale();
652
+ const timeZone = useReaderTimeZone();
792
653
  const chatActions = useAiChatActions();
793
654
  const layout = () => props.segment().layout;
794
655
  const results = createMemo(() => new Map(layout().results.map((result) => [result.id, result])));
@@ -829,7 +690,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
829
690
  </div>
830
691
  </Show>
831
692
  <For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
832
- <Show when={layout().text.length > 0}>
693
+ <Show when={props.segment().scheduledTask?.stop}>
694
+ {(stop) => (
695
+ <Chat.Activity
696
+ class="ai-turn__routine-line"
697
+ wrap
698
+ icon="ti ti-player-stop"
699
+ label={chatTaskStopSentence(stop().done, locale())}
700
+ />
701
+ )}
702
+ </Show>
703
+ <Show when={layout().text.length > 0 && !props.segment().scheduledTask?.stop && !props.segment().scheduledTask?.reasonOnly}>
833
704
  <AiTurnText
834
705
  blocks={() => layout().text}
835
706
  live={live}
@@ -838,6 +709,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
838
709
  earlier={() => props.segment().earlier}
839
710
  />
840
711
  </Show>
712
+ <Show when={props.segment().scheduledTask?.stop ? undefined : props.segment().scheduledTask?.reason}>
713
+ {(reason) => (
714
+ <Chat.Activity
715
+ class="ai-turn__routine-line"
716
+ wrap
717
+ icon={chatTaskReasonState(reason().code) === "paused" ? "ti ti-player-pause" : "ti ti-alert-triangle"}
718
+ tone={chatTaskReasonState(reason().code) === "paused" ? "neutral" : "warning"}
719
+ label={chatTaskReasonSentence(reason(), { locale: locale(), timeZone })}
720
+ />
721
+ )}
722
+ </Show>
841
723
  <For each={actionIds()}>
842
724
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
843
725
  </For>
@@ -10,7 +10,7 @@ import {
10
10
  useLocale,
11
11
  } from "@k2b/ui";
12
12
  import { createMemo, createSignal, createUniqueId, Index, type JSX, Match, onCleanup, onMount, Show, Switch } from "solid-js";
13
- import type { CloudAiChartRow } from "../chart-block";
13
+ import { type CloudAiChartRow, cloudAiNumberFormatters } from "../chart-block";
14
14
  import {
15
15
  CLOUD_AI_DETAILS_VIEWPORT_ITEMS,
16
16
  CLOUD_AI_VIEW_MAX_BYTES,
@@ -39,8 +39,11 @@ import { ReferenceText } from "./reference-text";
39
39
  const VISIBLE_ROWS = 100;
40
40
  /** Rows the viewport shows before it scrolls; it reserves their height from the first frame. */
41
41
  const VIEWPORT_ROWS = 8;
42
- /** A compact row: 12px text on a 16px line, 6px padding above and below, a 1px line. */
43
- const ROW_REM = 1.8125;
42
+ /** A compact DataTable row and its totals row, and the quieter header row above them. */
43
+ const ROW_REM = 2.5;
44
+ const HEADER_REM = 2.125;
45
+ /** DataTable draws a hairline between rows only above this many columns. */
46
+ const HAIRLINE_ABOVE_COLUMNS = 8;
44
47
 
45
48
  /** Loads a view file with the viewer's access and validates it again: a file is untrusted whoever wrote it. */
46
49
  async function loadView(url: string, path: string, signal: AbortSignal): Promise<CloudAiView | null> {
@@ -75,9 +78,16 @@ type ChartSummary = Extract<CloudAiViewSummary, { view: "chart" }>;
75
78
  type GroupSummary = Extract<CloudAiViewSummary, { view: "group" }>;
76
79
  type ExplorerSummary = Extract<CloudAiViewSummary, { view: "explorer" }>;
77
80
 
78
- /** The header row, the rows up to the viewport's, and the totals row when there is one. */
79
- const tableHeight = (summary: TableSummary) =>
80
- `${(Math.max(1, Math.min(summary.rows, VIEWPORT_ROWS)) + 1 + Number(summary.footer)) * ROW_REM}rem`;
81
+ /**
82
+ * The header row, the rows up to the viewport's, and the totals row when there is one, inside the paper's 1px frame.
83
+ * Wide tables add a 1px hairline after each visible body row with a following body row; the footer adds none.
84
+ */
85
+ const tableHeight = (summary: TableSummary) => {
86
+ const bodyRows = Math.max(1, Math.min(summary.rows, VIEWPORT_ROWS));
87
+ const rows = bodyRows + Number(summary.footer);
88
+ const lines = summary.columns > HAIRLINE_ABOVE_COLUMNS ? bodyRows - 1 + Number(summary.rows > VIEWPORT_ROWS) : 0;
89
+ return `calc(${HEADER_REM + rows * ROW_REM}rem + ${2 + lines}px)`;
90
+ };
81
91
 
82
92
  /**
83
93
  * A table view: title, Copy data and a sortable table in a viewport whose height follows the row count of its
@@ -244,12 +254,17 @@ function CloudStatsView(props: { summary: StatsSummary; input: CloudAiStatsView
244
254
  {(value) => (
245
255
  <StatGrid columns={columns()}>
246
256
  <Index each={value()}>
247
- {(tile) => (
257
+ {(tile, index) => (
248
258
  <StatCell
249
259
  label={tile().label}
250
260
  value={tile().value}
251
261
  title={tile().value}
252
262
  trend={tile().sparkline}
263
+ trendTooltip={({ datum }) => {
264
+ const point = tile().sparkline?.[datum.index];
265
+ const format = cloudAiNumberFormatters(props.input?.items[index]?.format, locale()).value;
266
+ return { rows: point === undefined ? [] : [{ label: tile().label, value: format(point) }] };
267
+ }}
253
268
  sub={tile().sub}
254
269
  accent={deltaAccent(tile().delta)}
255
270
  />
@@ -6,14 +6,25 @@ import { dates } from "@k2b/stdlib";
6
6
  import { z } from "zod";
7
7
  import { coreSettings } from "../services";
8
8
  import { normalizeWorkflowSchedule } from "../workflows/runtime";
9
+ import type { ChatTaskReason } from "./chat-task-reasons";
9
10
  import type { AiChatTask, AiChatTaskOccurrence, AiChatTaskOccurrenceState, AiChatTaskSchedule, AiChatTaskState } from "./chat-tasks";
10
11
  import { AI_SHORT_ID_PATTERN } from "./short-id";
11
12
 
12
- export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character scheduled task ID.");
13
- export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character task occurrence ID.");
13
+ export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine ID.");
14
+ export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine run ID.");
14
15
  export const AiConversationIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character AI conversation ID.");
15
16
 
17
+ export const chatTaskName = (task: { title?: string | null; chatTitle?: string | null; prompt: string }): string =>
18
+ task.title?.trim() ||
19
+ task.chatTitle?.trim() ||
20
+ Array.from(task.prompt.split(/\n|:\s/u)[0]!.trim())
21
+ .slice(0, 80)
22
+ .join("");
23
+
16
24
  export type AiChatTaskView = {
25
+ title: string | null;
26
+ name: string;
27
+ reason: ChatTaskReason | null;
17
28
  nextRunAt?: string | null;
18
29
  id: string;
19
30
  chatId: string;
@@ -42,6 +53,12 @@ export type AiChatTaskOccurrenceView = {
42
53
  };
43
54
 
44
55
  export const toAiChatTaskView = (task: AiChatTask): AiChatTaskView => ({
56
+ title: task.title,
57
+ name: task.name,
58
+ reason:
59
+ task.reasonCode && task.reasonAt
60
+ ? { code: task.reasonCode, at: task.reasonAt, ...(task.reasonAction ? { action: task.reasonAction } : {}) }
61
+ : null,
45
62
  id: task.shortId,
46
63
  chatId: task.chatId,
47
64
  chatTitle: task.chatTitle,
@@ -93,12 +110,23 @@ export const getChatTaskTimezone = async (): Promise<string> =>
93
110
  export const chatTaskCreateFingerprint = (input: {
94
111
  chatId: string;
95
112
  prompt: string;
113
+ title?: string;
96
114
  schedule: z.infer<typeof ChatTaskScheduleInputSchema>;
97
115
  timezone?: string;
98
116
  grants?: TaskGrant[];
99
117
  }): string =>
100
118
  new Bun.CryptoHasher("sha256")
101
- .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone ?? null, input.grants ?? []]))
119
+ .update(
120
+ JSON.stringify([
121
+ input.chatId,
122
+ input.prompt.trim(),
123
+ input.schedule,
124
+ input.timezone ?? null,
125
+ input.grants ?? [],
126
+ // Without a title the hash stays as before titles existed, so earlier idempotency keys still replay.
127
+ ...(input.title?.trim() ? [input.title.trim()] : []),
128
+ ]),
129
+ )
102
130
  .digest("hex");
103
131
 
104
132
  export const normalizeChatTaskSchedule = async (
@@ -0,0 +1,75 @@
1
+ import type { SQL } from "bun";
2
+ import { canonicalLocale } from "../shared/locale";
3
+
4
+ /** Called in a transaction; new columns and reason repairs commit together. */
5
+ export const migrateChatTaskReasons = async (sql: SQL): Promise<void> => {
6
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_code TEXT`.simple();
7
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_action TEXT`.simple();
8
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_at TIMESTAMPTZ`.simple();
9
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS title TEXT`.simple();
10
+ const [column] = await sql`SELECT 1 FROM information_schema.columns
11
+ WHERE table_schema = 'ai' AND table_name = 'chat_tasks' AND column_name = 'locale'`;
12
+ if (!column) {
13
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS locale TEXT`.simple();
14
+ // Data-sized work runs only with the new column. A restart never rescans old turns.
15
+ let cursor: string | null = null;
16
+ while (true) {
17
+ const rows: { id: string; locale: string | null }[] = await sql`
18
+ SELECT task.id, COALESCE(
19
+ (SELECT turn.run_config->>'locale' FROM ai.turns turn
20
+ WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
21
+ AND NOT (turn.run_config ? 'background') AND turn.created_at <= task.created_at
22
+ ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1),
23
+ (SELECT turn.run_config->>'locale' FROM ai.turns turn
24
+ WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
25
+ AND NOT (turn.run_config ? 'background')
26
+ ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1)) AS locale
27
+ FROM ai.chat_tasks task WHERE (${cursor}::uuid IS NULL OR task.id > ${cursor}::uuid)
28
+ ORDER BY task.id LIMIT 500`;
29
+ if (!rows.length) break;
30
+ for (const row of rows) {
31
+ const locale = canonicalLocale(row.locale);
32
+ if (locale) await sql`UPDATE ai.chat_tasks SET locale = ${locale} WHERE id = ${row.id}::uuid`;
33
+ }
34
+ cursor = rows[rows.length - 1]!.id;
35
+ }
36
+ }
37
+ await sql`
38
+ DO $$ BEGIN
39
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_code_check') THEN
40
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_code_check
41
+ CHECK (reason_code IN ('paused', 'chat_archived', 'owner_inactive', 'failed', 'rights_changed', 'needs_approval'));
42
+ END IF;
43
+ -- Bounded only: an older Core clears reason_code without knowing reason_action, so readers pair the two.
44
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_action_length_check') THEN
45
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_action_length_check
46
+ CHECK (reason_action IS NULL OR char_length(reason_action) BETWEEN 1 AND 200);
47
+ END IF;
48
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_title_check') THEN
49
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_title_check CHECK (char_length(btrim(title)) BETWEEN 1 AND 80);
50
+ END IF;
51
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_state_check') THEN
52
+ -- Repair legacy and rollback writes, preserving valid reasons and their timestamps.
53
+ UPDATE ai.chat_tasks SET reason_code = CASE
54
+ WHEN state NOT IN ('paused', 'needs_attention') THEN NULL
55
+ WHEN state = 'paused' THEN 'paused'
56
+ WHEN last_error IN ('Scheduled task mandate revision changed', 'Scheduled task mandate or sponsor is unavailable') THEN 'rights_changed'
57
+ ELSE 'failed' END,
58
+ reason_action = NULL,
59
+ reason_at = CASE WHEN state IN ('paused', 'needs_attention') THEN updated_at ELSE NULL END
60
+ WHERE NOT (
61
+ (state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
62
+ AND (reason_code IS NULL OR
63
+ (state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
64
+ (state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
65
+ );
66
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_state_check CHECK (
67
+ (state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
68
+ AND (reason_code IS NULL OR
69
+ (state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
70
+ (state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
71
+ );
72
+ END IF;
73
+ END $$
74
+ `.simple();
75
+ };
@@ -0,0 +1,92 @@
1
+ import { dates, i18n } from "@k2b/stdlib";
2
+ import { canonicalLocale } from "../shared/locale";
3
+
4
+ export const CHAT_TASK_REASON_CODES = ["paused", "chat_archived", "owner_inactive", "failed", "rights_changed", "needs_approval"] as const;
5
+ export type ChatTaskReasonCode = (typeof CHAT_TASK_REASON_CODES)[number];
6
+ export type ChatTaskReason = { code: ChatTaskReasonCode; at: string; action?: string };
7
+
8
+ export const chatTaskReasonState = (code: ChatTaskReasonCode): "paused" | "needs_attention" => {
9
+ switch (code) {
10
+ case "paused":
11
+ case "chat_archived":
12
+ case "owner_inactive":
13
+ return "paused";
14
+ case "failed":
15
+ case "rights_changed":
16
+ case "needs_approval":
17
+ return "needs_attention";
18
+ }
19
+ };
20
+
21
+ const messages = i18n.define({
22
+ baseLocale: "en",
23
+ messages: {
24
+ en: {
25
+ paused: ({ time }: { time: string }) => `Paused by you on ${time}. What happens meanwhile is not caught up automatically.`,
26
+ chat_archived: "Paused because the chat is archived.",
27
+ owner_inactive: "Paused because the account is inactive.",
28
+ failed: "The last run failed.",
29
+ rights_changed: "What it may do changed. Please confirm again.",
30
+ needs_approval: ({ action }: { action?: string }) =>
31
+ `${action ? `Tried to ${action.replace(/^\p{Lu}(?=\p{Ll})/u, (first) => first.toLowerCase())}. ` : ""}That needs your approval every time, and routines never ask.`,
32
+ stopped: ({ list }: { list: string }) =>
33
+ list
34
+ ? `Stopped while the routine was running. Already done: ${list}. Nothing was undone.`
35
+ : "Stopped while the routine was running. Nothing was undone.",
36
+ },
37
+ de: {
38
+ paused: ({ time }) => `Von dir pausiert am ${time}. Was in der Pause passiert, wird nicht von selbst nachgeholt.`,
39
+ chat_archived: "Pausiert, weil der Chat archiviert ist.",
40
+ owner_inactive: "Pausiert, weil das Konto inaktiv ist.",
41
+ failed: "Der letzte Lauf ist fehlgeschlagen.",
42
+ rights_changed: "Was sie darf, hat sich geändert. Bitte neu bestätigen.",
43
+ needs_approval: ({ action }) => `${action ? `Wollte ${action}. ` : ""}Das macht der Assistant nur mit dir, und Routinen fragen nie.`,
44
+ stopped: ({ list }) =>
45
+ list
46
+ ? `Angehalten, während die Routine lief. Schon erledigt: ${list}. Nichts wurde rückgängig gemacht.`
47
+ : "Angehalten, während die Routine lief. Nichts wurde rückgängig gemacht.",
48
+ },
49
+ },
50
+ });
51
+
52
+ /**
53
+ * A one-time routine resumes only before its time: a run at that time already happened or was stopped, and what was
54
+ * missed in a pause is not caught up. It needs a new time first.
55
+ */
56
+ export const chatTaskNeedsNewTime = (
57
+ task: {
58
+ state: "active" | "paused" | "completed" | "needs_attention";
59
+ schedule: { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
60
+ },
61
+ now: Date = new Date(),
62
+ ): boolean =>
63
+ task.state !== "active" &&
64
+ task.schedule.kind === "once" &&
65
+ (task.state === "needs_attention" || Date.parse(task.schedule.runAt) <= now.getTime());
66
+
67
+ export const chatTaskLocale = (taskLocale: string | null | undefined, appLocale?: string | null): string =>
68
+ canonicalLocale(taskLocale) ?? canonicalLocale(appLocale) ?? "en";
69
+
70
+ export const chatTaskReasonSentence = (reason: ChatTaskReason, context: { locale: string; timeZone: string }): string => {
71
+ const t = messages.resolve([context.locale]).t;
72
+ // The record is exhaustive: a new reason must have a sentence in both catalogs.
73
+ const sentences: Record<ChatTaskReasonCode, () => string> = {
74
+ paused: () => t.paused({ time: dates.formatDateTime(reason.at, context) }),
75
+ chat_archived: () => t.chat_archived,
76
+ owner_inactive: () => t.owner_inactive,
77
+ failed: () => t.failed,
78
+ rights_changed: () => t.rights_changed,
79
+ needs_approval: () => t.needs_approval({ action: reason.action }),
80
+ };
81
+ return sentences[reason.code]();
82
+ };
83
+
84
+ // A scan from the end instead of /[.!?]+$/, which backtracks quadratically on long runs of punctuation.
85
+ const withoutClosingPunctuation = (text: string): string => {
86
+ let end = text.length;
87
+ while (end > 0 && ".!?".includes(text[end - 1]!)) end -= 1;
88
+ return text.slice(0, end);
89
+ };
90
+
91
+ export const chatTaskStopSentence = (done: readonly string[], locale: string): string =>
92
+ messages.resolve([locale]).t.stopped({ list: done.map(withoutClosingPunctuation).join("; ") });