@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -527,15 +527,38 @@ export type CloudAiChartColumn = { id: string; label: string; numeric: boolean }
527
527
  export type CloudAiChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
528
528
  export type CloudAiChartTable = { columns: CloudAiChartColumn[]; rows: CloudAiChartRow[] };
529
529
 
530
+ /** The first text column titles the tooltip; the other columns become rows, matching the table in every host. */
531
+ export function cloudAiChartTooltip(
532
+ columns: CloudAiChartColumn[],
533
+ row: CloudAiChartRow,
534
+ ): { title?: string; rows: { label: string; value: string }[] } {
535
+ const [first, ...rest] = columns;
536
+ const named = first && !first.numeric;
537
+ return {
538
+ title: named ? row.cells[first.id] : undefined,
539
+ rows: (named ? rest : columns)
540
+ .filter((column) => column.id in row.cells)
541
+ .map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
542
+ };
543
+ }
544
+
530
545
  /**
531
546
  * Builds the data table mark by mark, in the order the renderer emits them, so the chat block fills it from the same
532
547
  * drawing that shows the chart.
533
548
  */
534
- export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
549
+ export function cloudAiChartRows(
550
+ input: CloudAiChartInput,
551
+ locale: string,
552
+ options: { formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string } } = {},
553
+ ) {
535
554
  const t = labels.resolve([locale]).t;
536
555
  const format = formats(input, locale);
556
+ const valueFormat = input.tooltip?.format
557
+ ? cloudAiNumberFormatters(input.tooltip.format, locale).value
558
+ : (options.formats?.value ?? (input.kind === "histogram" ? undefined : options.formats?.y) ?? format.value);
559
+ const xFormat = options.formats?.x ?? format.x.value;
537
560
  const series = seriesOf(input);
538
- const countFormat = cloudAiNumberFormatters(undefined, locale).value;
561
+ const countFormat = (input.kind === "histogram" ? options.formats?.y : undefined) ?? cloudAiNumberFormatters(undefined, locale).value;
539
562
  const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
540
563
  const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
541
564
  const columns = new Map<string, CloudAiChartColumn>();
@@ -575,26 +598,34 @@ export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
575
598
  "x",
576
599
  input.kind === "sparkline" || input.kind === "stat" ? "#" : header,
577
600
  value,
578
- input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : format.x.value(value),
601
+ input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : xFormat(value),
579
602
  );
580
603
  } else if (field.key === "y") {
581
604
  const header =
605
+ input.tooltip?.label ??
582
606
  (input.kind === "line" || input.kind === "scatter" ? input.yAxis?.label : undefined) ??
583
607
  (input.kind === "sparkline" || input.kind === "stat" ? t.value : "Y");
584
- cell("y", header, value, format.value(value));
608
+ cell("y", header, value, valueFormat(value));
585
609
  } else if (field.key === "value")
586
- cell("value", (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value, value, `${format.value(value)}${unit}`);
587
- else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${format.value(value)}`);
588
- else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
589
- cell(field.key, t[field.key], value, format.value(value));
590
- else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
591
- else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max")
592
610
  cell(
593
- field.key,
594
- t[field.key],
611
+ "value",
612
+ input.tooltip?.label ?? (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value,
595
613
  value,
596
- `${field.key === "count" ? countFormat(value) : format.value(value)}${field.key === "min" || field.key === "max" ? unit : ""}`,
614
+ `${valueFormat(value)}${unit}`,
597
615
  );
616
+ else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${valueFormat(value)}`);
617
+ else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
618
+ cell(field.key, t[field.key], value, valueFormat(value));
619
+ else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
620
+ else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max") {
621
+ const fieldFormat =
622
+ field.key === "count"
623
+ ? countFormat
624
+ : input.kind === "histogram" && (field.key === "from" || field.key === "to") && options.formats?.x
625
+ ? xFormat
626
+ : valueFormat;
627
+ cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
628
+ }
598
629
  }
599
630
  const row = { key: cloudAiChartMarkKey(datum), cells, values };
600
631
  rows.push(row);
@@ -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";
@@ -9,7 +9,6 @@ import {
9
9
  } from "@k2b/ui";
10
10
  import { createMemo, createUniqueId, Index, type JSX, Show } from "solid-js";
11
11
  import {
12
- type CloudAiChartColumn,
13
12
  type CloudAiChartRow,
14
13
  type CloudAiChartTable,
15
14
  cloudAiChartLegend,
@@ -17,21 +16,13 @@ import {
17
16
  cloudAiChartReferenceMark,
18
17
  cloudAiChartRenderOptions,
19
18
  cloudAiChartRows,
19
+ cloudAiChartTooltip,
20
20
  cloudAiChartXValueFormat,
21
21
  } from "../chart-block";
22
22
  import type { CloudAiChartInput } from "../default-tool-contracts";
23
23
  import { type CloudAiGroupView, parseCloudAiChartToolView } from "../view-block";
24
24
  import { aiChatMessages } from "./messages";
25
25
 
26
- const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
27
- const [first, ...rest] = columns;
28
- const named = first && !first.numeric;
29
- return {
30
- title: named ? row.cells[first.id] : undefined,
31
- rows: (named ? rest : columns).map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
32
- };
33
- };
34
-
35
26
  type Kind = CloudAiChartInput["kind"];
36
27
  type DrawnChart = { input: CloudAiChartInput; table: CloudAiChartTable; snapshot: ChartSnapshot; legend: ChartLegendEntry[] };
37
28
 
@@ -44,7 +35,7 @@ const drawChart = (input: CloudAiChartInput, locale: string): DrawnChart | null
44
35
  key: ({ datum }) => cloudAiChartMarkKey(datum),
45
36
  tooltip: ({ datum }) => {
46
37
  const row = rows.add(datum);
47
- return tooltip(rows.columns(), row);
38
+ return cloudAiChartTooltip(rows.columns(), row);
48
39
  },
49
40
  reference: ({ datum }) => cloudAiChartReferenceMark(input, datum),
50
41
  });
@@ -137,6 +137,11 @@ const messages = i18n.define({
137
137
  revokeFailed: "Could not revoke. Try again.",
138
138
  revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
139
139
  websiteRevoked: "Revoked. The next request asks again.",
140
+ fetchingFile: ({ name }: { name: string }) => `Fetching ${name}`,
141
+ fetchedFile: ({ name }: { name: string }) => `Fetched ${name}`,
142
+ fetchSource: "Source",
143
+ fetchSavedAs: "Saved as",
144
+ fetchReadableCopy: "Readable copy",
140
145
  details: "Details",
141
146
  hideDetails: "Hide details",
142
147
  moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
@@ -446,6 +451,11 @@ const messages = i18n.define({
446
451
  revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
447
452
  revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
448
453
  websiteRevoked: "Entzogen. Der nächste Abruf fragt wieder.",
454
+ fetchingFile: ({ name }) => `Lädt herunter: ${name}`,
455
+ fetchedFile: ({ name }) => `Heruntergeladen: ${name}`,
456
+ fetchSource: "Quelle",
457
+ fetchSavedAs: "Gespeichert als",
458
+ fetchReadableCopy: "Lesbare Kopie",
449
459
  details: "Details",
450
460
  hideDetails: "Details ausblenden",
451
461
  moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
@@ -16,6 +16,7 @@ import {
16
16
  untrack,
17
17
  } from "solid-js";
18
18
  import { markdown } from "../../shared";
19
+ import { type ChatTaskReason, chatTaskReasonSentence, chatTaskReasonState, chatTaskStopSentence } from "../chat-task-reasons";
19
20
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
20
21
  import type { AiTurnError } from "../types";
21
22
  import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
@@ -65,8 +66,17 @@ export type AiTurnSegment = {
65
66
  earlier: boolean;
66
67
  /** Read reactively, so a ticking clock does not rebuild the segment. */
67
68
  duration: () => AiTurnDuration | null;
68
- /** Shown above the work of a delivered scheduled result. */
69
- scheduledTask?: { taskId: string; occurrenceId: string } | null;
69
+ /**
70
+ * Shown above the work of a delivered scheduled result. `stop` and `reason` add a system line the chat words in
71
+ * the reader's language; with `stop` or `reasonOnly` that line replaces the stored text.
72
+ */
73
+ scheduledTask?: {
74
+ taskId: string;
75
+ occurrenceId: string;
76
+ reason?: ChatTaskReason;
77
+ reasonOnly?: true;
78
+ stop?: { cause: "paused" | "deleted"; done: string[] };
79
+ } | null;
70
80
  /**
71
81
  * The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
72
82
  * reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
@@ -789,6 +799,7 @@ function AiTurnText(props: {
789
799
  */
790
800
  export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosureState: AiToolDisclosureState }): JSX.Element {
791
801
  const locale = useLocale();
802
+ const timeZone = useReaderTimeZone();
792
803
  const chatActions = useAiChatActions();
793
804
  const layout = () => props.segment().layout;
794
805
  const results = createMemo(() => new Map(layout().results.map((result) => [result.id, result])));
@@ -829,7 +840,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
829
840
  </div>
830
841
  </Show>
831
842
  <For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
832
- <Show when={layout().text.length > 0}>
843
+ <Show when={props.segment().scheduledTask?.stop}>
844
+ {(stop) => (
845
+ <Chat.Activity
846
+ class="ai-turn__routine-line"
847
+ wrap
848
+ icon="ti ti-player-stop"
849
+ label={chatTaskStopSentence(stop().done, locale())}
850
+ />
851
+ )}
852
+ </Show>
853
+ <Show when={layout().text.length > 0 && !props.segment().scheduledTask?.stop && !props.segment().scheduledTask?.reasonOnly}>
833
854
  <AiTurnText
834
855
  blocks={() => layout().text}
835
856
  live={live}
@@ -838,6 +859,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
838
859
  earlier={() => props.segment().earlier}
839
860
  />
840
861
  </Show>
862
+ <Show when={props.segment().scheduledTask?.stop ? undefined : props.segment().scheduledTask?.reason}>
863
+ {(reason) => (
864
+ <Chat.Activity
865
+ class="ai-turn__routine-line"
866
+ wrap
867
+ icon={chatTaskReasonState(reason().code) === "paused" ? "ti ti-player-pause" : "ti ti-alert-triangle"}
868
+ tone={chatTaskReasonState(reason().code) === "paused" ? "neutral" : "warning"}
869
+ label={chatTaskReasonSentence(reason(), { locale: locale(), timeZone })}
870
+ />
871
+ )}
872
+ </Show>
841
873
  <For each={actionIds()}>
842
874
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
843
875
  </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,
@@ -244,12 +244,17 @@ function CloudStatsView(props: { summary: StatsSummary; input: CloudAiStatsView
244
244
  {(value) => (
245
245
  <StatGrid columns={columns()}>
246
246
  <Index each={value()}>
247
- {(tile) => (
247
+ {(tile, index) => (
248
248
  <StatCell
249
249
  label={tile().label}
250
250
  value={tile().value}
251
251
  title={tile().value}
252
252
  trend={tile().sparkline}
253
+ trendTooltip={({ datum }) => {
254
+ const point = tile().sparkline?.[datum.index];
255
+ const format = cloudAiNumberFormatters(props.input?.items[index]?.format, locale()).value;
256
+ return { rows: point === undefined ? [] : [{ label: tile().label, value: format(point) }] };
257
+ }}
253
258
  sub={tile().sub}
254
259
  accent={deltaAccent(tile().delta)}
255
260
  />
@@ -136,15 +136,22 @@ export function WebExtractToolBlock(props: { block: ToolBlock }) {
136
136
  );
137
137
  }
138
138
 
139
- type FetchFileResult = { path: string; size: number; mediaType: string; url: string };
139
+ type FetchFileResult = { path: string; size: number; mediaType: string; url: string; readablePath?: string };
140
140
 
141
+ /** Results stored before readable copies existed have no `readable` field and still render. */
141
142
  const fetchFileResult = (value: unknown): FetchFileResult | null =>
142
143
  isRecord(value) &&
143
144
  typeof value.path === "string" &&
144
145
  typeof value.size === "number" &&
145
146
  typeof value.mediaType === "string" &&
146
147
  typeof value.url === "string"
147
- ? { path: value.path, size: value.size, mediaType: value.mediaType, url: value.url }
148
+ ? {
149
+ path: value.path,
150
+ size: value.size,
151
+ mediaType: value.mediaType,
152
+ url: value.url,
153
+ ...(isRecord(value.readable) && typeof value.readable.path === "string" ? { readablePath: value.readable.path } : {}),
154
+ }
148
155
  : null;
149
156
 
150
157
  const filenameOf = (value: string): string => {
@@ -158,6 +165,8 @@ const filenameOf = (value: string): string => {
158
165
 
159
166
  /** Imported web file: source identity, useful metadata, and chat path without raw tool JSON. */
160
167
  export function FetchFileToolBlock(props: { block: ToolBlock }) {
168
+ const locale = useLocale();
169
+ const t = () => aiChatMessages(locale());
161
170
  const sourceUrl = () => (isRecord(props.block.args) && typeof props.block.args.url === "string" ? props.block.args.url : "");
162
171
  const result = () => fetchFileResult(props.block.result);
163
172
  const displayName = () => filenameOf(result()?.path ?? sourceUrl()) || "file";
@@ -172,7 +181,7 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
172
181
  when={props.block.status === "completed" && result()}
173
182
  fallback={
174
183
  <Chat.Activity
175
- label={`Fetching ${displayName()}`}
184
+ label={t().fetchingFile({ name: displayName() })}
176
185
  description={domainOf(sourceUrl())}
177
186
  leading={<Favicon url={sourceUrl()} fallbackIcon="ti ti-world-download" />}
178
187
  busy
@@ -183,19 +192,27 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
183
192
  <AiToolActivity
184
193
  blockId={props.block.id}
185
194
  leading={<Favicon url={sourceUrl() || file().url} fallbackIcon="ti ti-world-download" />}
186
- label={`Fetched ${displayName()}`}
195
+ label={t().fetchedFile({ name: displayName() })}
187
196
  description={description()}
188
197
  bodyInset={false}
189
198
  >
190
199
  <dl class="grid w-full min-w-0 grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 rounded-md bg-zinc-100/70 px-2 py-1.5 text-xs [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
191
- <dt class="font-medium text-dimmed">Source</dt>
200
+ <dt class="font-medium text-dimmed">{t().fetchSource}</dt>
192
201
  <dd class="min-w-0 truncate">
193
202
  <a href={file().url} target="_blank" rel="noreferrer noopener" class="text-secondary hover:text-primary">
194
203
  {file().url}
195
204
  </a>
196
205
  </dd>
197
- <dt class="font-medium text-dimmed">Saved as</dt>
206
+ <dt class="font-medium text-dimmed">{t().fetchSavedAs}</dt>
198
207
  <dd class="min-w-0 truncate text-secondary">{file().path}</dd>
208
+ <Show when={file().readablePath}>
209
+ {(readablePath) => (
210
+ <>
211
+ <dt class="font-medium text-dimmed">{t().fetchReadableCopy}</dt>
212
+ <dd class="min-w-0 truncate text-secondary">{readablePath()}</dd>
213
+ </>
214
+ )}
215
+ </Show>
199
216
  </dl>
200
217
  </AiToolActivity>
201
218
  )}
@@ -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,9 @@ 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: task.reasonCode && task.reasonAt ? { code: task.reasonCode, at: task.reasonAt } : null,
45
59
  id: task.shortId,
46
60
  chatId: task.chatId,
47
61
  chatTitle: task.chatTitle,
@@ -93,12 +107,23 @@ export const getChatTaskTimezone = async (): Promise<string> =>
93
107
  export const chatTaskCreateFingerprint = (input: {
94
108
  chatId: string;
95
109
  prompt: string;
110
+ title?: string;
96
111
  schedule: z.infer<typeof ChatTaskScheduleInputSchema>;
97
112
  timezone?: string;
98
113
  grants?: TaskGrant[];
99
114
  }): string =>
100
115
  new Bun.CryptoHasher("sha256")
101
- .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone ?? null, input.grants ?? []]))
116
+ .update(
117
+ JSON.stringify([
118
+ input.chatId,
119
+ input.prompt.trim(),
120
+ input.schedule,
121
+ input.timezone ?? null,
122
+ input.grants ?? [],
123
+ // Without a title the hash stays as before titles existed, so earlier idempotency keys still replay.
124
+ ...(input.title?.trim() ? [input.title.trim()] : []),
125
+ ]),
126
+ )
102
127
  .digest("hex");
103
128
 
104
129
  export const normalizeChatTaskSchedule = async (
@@ -0,0 +1,68 @@
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_at TIMESTAMPTZ`.simple();
8
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS title TEXT`.simple();
9
+ const [column] = await sql`SELECT 1 FROM information_schema.columns
10
+ WHERE table_schema = 'ai' AND table_name = 'chat_tasks' AND column_name = 'locale'`;
11
+ if (!column) {
12
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS locale TEXT`.simple();
13
+ // Data-sized work runs only with the new column. A restart never rescans old turns.
14
+ let cursor: string | null = null;
15
+ while (true) {
16
+ const rows: { id: string; locale: string | null }[] = await sql`
17
+ SELECT task.id, COALESCE(
18
+ (SELECT turn.run_config->>'locale' FROM ai.turns turn
19
+ WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
20
+ AND NOT (turn.run_config ? 'background') AND turn.created_at <= task.created_at
21
+ ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1),
22
+ (SELECT turn.run_config->>'locale' FROM ai.turns turn
23
+ WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
24
+ AND NOT (turn.run_config ? 'background')
25
+ ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1)) AS locale
26
+ FROM ai.chat_tasks task WHERE (${cursor}::uuid IS NULL OR task.id > ${cursor}::uuid)
27
+ ORDER BY task.id LIMIT 500`;
28
+ if (!rows.length) break;
29
+ for (const row of rows) {
30
+ const locale = canonicalLocale(row.locale);
31
+ if (locale) await sql`UPDATE ai.chat_tasks SET locale = ${locale} WHERE id = ${row.id}::uuid`;
32
+ }
33
+ cursor = rows[rows.length - 1]!.id;
34
+ }
35
+ }
36
+ await sql`
37
+ DO $$ BEGIN
38
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_code_check') THEN
39
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_code_check
40
+ CHECK (reason_code IN ('paused', 'chat_archived', 'owner_inactive', 'failed', 'rights_changed', 'needs_approval'));
41
+ END IF;
42
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_title_check') THEN
43
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_title_check CHECK (char_length(btrim(title)) BETWEEN 1 AND 80);
44
+ END IF;
45
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_state_check') THEN
46
+ -- Repair legacy and rollback writes, preserving valid reasons and their timestamps.
47
+ UPDATE ai.chat_tasks SET reason_code = CASE
48
+ WHEN state NOT IN ('paused', 'needs_attention') THEN NULL
49
+ WHEN state = 'paused' THEN 'paused'
50
+ WHEN last_error IN ('Scheduled task mandate revision changed', 'Scheduled task mandate or sponsor is unavailable') THEN 'rights_changed'
51
+ ELSE 'failed' END,
52
+ reason_at = CASE WHEN state IN ('paused', 'needs_attention') THEN updated_at ELSE NULL END
53
+ WHERE NOT (
54
+ (state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
55
+ AND (reason_code IS NULL OR
56
+ (state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
57
+ (state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
58
+ );
59
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_state_check CHECK (
60
+ (state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
61
+ AND (reason_code IS NULL OR
62
+ (state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
63
+ (state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
64
+ );
65
+ END IF;
66
+ END $$
67
+ `.simple();
68
+ };
@@ -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}. ` : ""}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("; ") });