@k2b/cloud 0.29.1 → 0.30.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 (77) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +45 -9
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/message-utils.ts +3 -0
  17. package/src/ai/chat/messages.ts +8 -2
  18. package/src/ai/chat/tool-names.ts +2 -0
  19. package/src/ai/chat/turn-layout.ts +2 -2
  20. package/src/ai/chat/turn-view.tsx +29 -11
  21. package/src/ai/code-runtime-tools.ts +59 -44
  22. package/src/ai/default-tool-contracts.ts +198 -0
  23. package/src/ai/default-tools.ts +25 -1
  24. package/src/ai/executor.ts +19 -21
  25. package/src/ai/index.ts +9 -1
  26. package/src/ai/migrate.ts +59 -12
  27. package/src/ai/runtime-tools.ts +1 -0
  28. package/src/ai/skill-content.ts +54 -0
  29. package/src/ai/skills-routes.ts +22 -13
  30. package/src/ai/skills.ts +177 -231
  31. package/src/ai/tool-sentences.ts +38 -0
  32. package/src/ai/types.ts +8 -1
  33. package/src/api/admin-ai-skills.ts +35 -16
  34. package/src/api/fanout.ts +128 -0
  35. package/src/api/help.ts +8 -3
  36. package/src/api/mcp.ts +4 -1
  37. package/src/api/search.ts +31 -106
  38. package/src/api/widgets.ts +259 -100
  39. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  40. package/src/browser/ndjson.ts +34 -0
  41. package/src/browser/reload.ts +46 -0
  42. package/src/browser/search-stream.ts +4 -26
  43. package/src/browser/widgets.ts +28 -0
  44. package/src/capabilities/server.ts +20 -2
  45. package/src/cli/access.ts +48 -14
  46. package/src/cli/admin/ai-skills.ts +59 -39
  47. package/src/contracts/announcement-cookie.ts +62 -0
  48. package/src/contracts/announcements.ts +14 -52
  49. package/src/contracts/capabilities.ts +24 -0
  50. package/src/contracts/pwa-paths.ts +7 -2
  51. package/src/contracts/pwa.ts +2 -7
  52. package/src/contracts/registry.ts +1 -0
  53. package/src/contracts/shared.ts +5 -0
  54. package/src/contracts/widgets.ts +20 -0
  55. package/src/index.ts +1 -0
  56. package/src/server/help.ts +8 -1
  57. package/src/services/accounts/users.ts +15 -0
  58. package/src/services/help/index.ts +18 -5
  59. package/src/services/help/types.ts +3 -2
  60. package/src/services/identity/constants.ts +2 -0
  61. package/src/services/identity/key-ring.ts +123 -42
  62. package/src/services/identity/runtime-config.ts +13 -2
  63. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  64. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  65. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  66. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  67. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  68. package/src/ssr/PwaRuntime.island.tsx +1 -1
  69. package/src/ssr/app-navigation.ts +5 -7
  70. package/src/ssr/global-search-host.tsx +55 -0
  71. package/src/ssr/help.ts +3 -3
  72. package/src/ssr/rail-context.ts +4 -3
  73. package/src/ssr/runtime.ts +14 -1
  74. package/src/ai/code-mode-skill.ts +0 -114
  75. package/src/ai/data-analysis-skill.ts +0 -10
  76. package/src/ai/grids-skill.ts +0 -158
  77. package/src/ai/skill-seeds.ts +0 -560
@@ -2,12 +2,15 @@ import { dates } from "@k2b/stdlib";
2
2
  import { mutation } from "@k2b/stdlib/solid";
3
3
  import { Button, ButtonLink, Chat, isStructuredDataValue, MarkdownView, SplitButton, StructuredDataPreview, useLocale } from "@k2b/ui";
4
4
  import { createSignal, createUniqueId, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
5
+ import { capabilityApprovalReason, capabilityApprovalReasonLabel } from "../../_internal/capability-sentences";
5
6
  import type { CapabilityActionReview } from "../../contracts/capabilities";
6
7
  import { markdown } from "../../shared";
7
8
  import type { AiTurnBlock } from "../protocol";
9
+ import { toolOutcome, toolSubject } from "./action-sentences";
8
10
  import { hasSpecializedBuiltinToolView, SpecializedBuiltinToolBlock } from "./builtin-tools";
9
11
  import { hasCapabilityTable } from "./capability-result";
10
12
  import { CapabilityTablePreview } from "./capability-table";
13
+ import { CloudChartBlock } from "./chart-block";
11
14
  import { PresentToolBlock } from "./file-tools";
12
15
  import { useAiChatActions } from "./message-actions";
13
16
  import {
@@ -18,6 +21,7 @@ import {
18
21
  fetchFileErrorPresentation,
19
22
  formatToolDetailText,
20
23
  isCardToolName,
24
+ isChartToolName,
21
25
  isRecord,
22
26
  isSurveyToolName,
23
27
  isTextEditorToolName,
@@ -216,11 +220,17 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
216
220
  decision = { callId: props.block.callId, approved: input.approved };
217
221
  void approval.mutate(input);
218
222
  };
219
- const title = () => props.block.presentation?.title ?? displayToolName(props.block.name, locale());
223
+ // The heading says what will happen in the owning app's words; the app or the assistant name stays beside it.
224
+ const title = () => toolSubject(props.block, locale()) ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale());
220
225
  const ownerName = () => props.block.presentation?.appName ?? t().assistant;
226
+ const capability = () => props.block.presentation?.kind === "capability";
227
+ // The model's own reason, labelled as such, only where Cloud offered it; it adds to the app's sentence and never replaces it.
228
+ const reason = () => (props.block.presentation?.approvalReason ? capabilityApprovalReason(props.block.args) : null);
221
229
  const description = () => {
222
230
  const reviewMessage = props.block.approval?.review?.message.trim();
223
231
  if (reviewMessage) return reviewMessage;
232
+ // An app Action's approval text repeats its heading and reason for text-only readers; the card shows them itself.
233
+ if (capability()) return null;
224
234
  const message = props.block.approval?.message?.trim();
225
235
  if (!message) return null;
226
236
  const duplicateTitle = props.block.presentation ? `${props.block.presentation.appName}: ${props.block.presentation.title}` : "";
@@ -259,10 +269,10 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
259
269
  <div class="ai-approval__head">
260
270
  <i class={`ai-approval__icon ${aiToolIcon(props.block.name, props.block.presentation?.appIcon)}`} aria-hidden="true" />
261
271
  <div class="min-w-0">
262
- <h3 class="ai-approval__title">
263
- {ownerName()} · {title()}
264
- </h3>
265
- <p class="ai-approval__sub">{t().approvalRunsAfter}</p>
272
+ <h3 class="ai-approval__title">{title()}</h3>
273
+ <p class="ai-approval__sub">
274
+ {ownerName()} · {t().approvalRunsAfter}
275
+ </p>
266
276
  </div>
267
277
  </div>
268
278
  <Show when={reviewLines().length > 0}>
@@ -318,6 +328,14 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
318
328
  )}
319
329
  </For>
320
330
  </Show>
331
+ <Show when={reason()}>
332
+ {(text) => (
333
+ <p class="ai-approval__text whitespace-pre-wrap">
334
+ <strong class="ai-approval__label">{capabilityApprovalReasonLabel(locale())}: </strong>
335
+ {text()}
336
+ </p>
337
+ )}
338
+ </Show>
321
339
  <Show when={detailsOpen()}>
322
340
  <div id={detailsId} class="w-full min-w-0" role="region" aria-label={t().detailsFor({ title: title() })}>
323
341
  <Show when={detailData()} fallback={<pre class="ai-approval__preview text-[11px]">{jsonPreview(props.block.args)}</pre>}>
@@ -518,11 +536,11 @@ function CapabilityToolView(props: { block: ToolBlock }) {
518
536
  function RejectedToolView(props: { block: ToolBlock }) {
519
537
  const locale = useLocale();
520
538
  const presentation = () => props.block.presentation;
521
- const title = () => presentation()?.title ?? displayToolName(props.block.name, locale());
539
+ const title = () => toolSubject(props.block, locale()) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
522
540
  return (
523
541
  <Chat.Activity
524
542
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
525
- label={aiChatMessages(locale()).toolRejected({ title: title() })}
543
+ label={toolOutcome(props.block, "rejected", locale()) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
526
544
  accent={presentation()?.appAccent}
527
545
  />
528
546
  );
@@ -694,6 +712,9 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
694
712
  <Match when={isCardToolName(props.block.name) && !props.block.isError}>
695
713
  <CloudCardBlock args={props.block.args} />
696
714
  </Match>
715
+ <Match when={isChartToolName(props.block.name) && !props.block.isError}>
716
+ <CloudChartBlock args={props.block.args} completed={status() === "completed"} />
717
+ </Match>
697
718
  <Match when={isSurveyToolName(props.block.name) && !props.block.isError}>
698
719
  <SurveyToolView turnId={props.turnId} block={props.block} active={props.active} />
699
720
  </Match>
@@ -791,7 +812,22 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
791
812
  const locale = useLocale();
792
813
  const t = () => aiChatMessages(locale());
793
814
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
794
- const detail = () => [args().path, args().name, args().query, args().title].find((value) => typeof value === "string");
815
+ /**
816
+ * A call that acts on something reads as its sentence, which already names what it acts on: what it will do while
817
+ * it runs or waits, what it did once done, and what did not happen when it was rejected.
818
+ */
819
+ const sentence = () => {
820
+ const status = props.block.status;
821
+ const outcome =
822
+ status === "completed" && !props.block.isError
823
+ ? toolOutcome(props.block, "done", locale())
824
+ : status === "rejected"
825
+ ? toolOutcome(props.block, "rejected", locale())
826
+ : null;
827
+ return outcome ?? toolSubject(props.block, locale());
828
+ };
829
+ const detail = () =>
830
+ sentence() ? undefined : [args().path, args().name, args().query, args().title].find((value) => typeof value === "string");
795
831
  const waiting = () => props.block.status === "awaiting_client" || props.block.status === "awaiting_approval";
796
832
  const state = () =>
797
833
  waiting() ? t().stepWaiting : props.block.status === "rejected" ? t().stepRejected : isFailedTool(props.block) ? t().stepFailed : "";
@@ -819,7 +855,7 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
819
855
  return (
820
856
  <AiToolActivity
821
857
  blockId={props.block.id}
822
- label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
858
+ label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
823
859
  description={[
824
860
  props.block.status === "running" ? (props.block.progress ?? "") : "",
825
861
  typeof detail() === "string" ? String(detail()) : "",
@@ -0,0 +1,79 @@
1
+ import { ChartExplorer, type ChartExplorerColumn, prepareChartSnapshot, useLocale } from "@k2b/ui";
2
+ import { createMemo, Show } from "solid-js";
3
+ import {
4
+ type CloudAiChartColumn,
5
+ type CloudAiChartRow,
6
+ cloudAiChartMarkKey,
7
+ cloudAiChartRenderOptions,
8
+ cloudAiChartRows,
9
+ } from "../chart-block";
10
+ import { parseCloudAiChartInput } from "../default-tool-contracts";
11
+ import { aiChatMessages } from "./messages";
12
+
13
+ const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
14
+ const [first, ...rest] = columns;
15
+ const named = first && !first.numeric;
16
+ return {
17
+ title: named ? row.cells[first.id] : undefined,
18
+ rows: (named ? rest : columns).map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
19
+ };
20
+ };
21
+
22
+ /**
23
+ * One `chart` tool call: the shared chart with its data table and Copy data. The block takes its full size with its
24
+ * first frame and keeps it; switching to the table scrolls inside the same height. A call whose arguments are not a
25
+ * valid chart shows nothing while it runs, because the model gets the validation error and the call fails.
26
+ */
27
+ export function CloudChartBlock(props: { args: unknown; completed: boolean }) {
28
+ const locale = useLocale();
29
+ // Live updates replace the block with equal arguments; only a real change redraws.
30
+ const source = createMemo(() => JSON.stringify(props.args ?? null));
31
+ const chart = createMemo(() => {
32
+ // The tool schema drew the chart once to accept it; here it is drawn once more, for display, and the table is
33
+ // filled from that drawing.
34
+ const input = parseCloudAiChartInput(JSON.parse(source()));
35
+ if (!input) return null;
36
+ try {
37
+ const rows = cloudAiChartRows(input, locale());
38
+ const snapshot = prepareChartSnapshot(cloudAiChartRenderOptions(input, locale()), {
39
+ key: ({ datum }) => cloudAiChartMarkKey(datum),
40
+ tooltip: ({ datum }) => {
41
+ const row = rows.add(datum);
42
+ return tooltip(rows.columns(), row);
43
+ },
44
+ });
45
+ return snapshot.marks.length > 0 ? { input, table: rows.table(), snapshot } : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ });
50
+ return (
51
+ <Show
52
+ when={chart()}
53
+ fallback={
54
+ <Show when={props.completed}>
55
+ <p class="text-sm text-secondary">{aiChatMessages(locale()).chartUnavailable}</p>
56
+ </Show>
57
+ }
58
+ >
59
+ {(value) => (
60
+ <ChartExplorer
61
+ title={value().input.title}
62
+ description={value().input.subtitle ? <span class="text-sm text-secondary">{value().input.subtitle}</span> : undefined}
63
+ height={value().input.kind === "sparkline" ? "8rem" : "18rem"}
64
+ data={{ chart: value().snapshot, rows: value().table.rows }}
65
+ columns={value().table.columns.map(
66
+ (column): ChartExplorerColumn<CloudAiChartRow> => ({
67
+ id: column.id,
68
+ label: column.label,
69
+ value: (row) => row.cells[column.id] ?? "",
70
+ sortValue: (row) => row.values[column.id] ?? null,
71
+ align: column.numeric ? "right" : "left",
72
+ }),
73
+ )}
74
+ renderDetails={false}
75
+ />
76
+ )}
77
+ </Show>
78
+ );
79
+ }
@@ -311,6 +311,8 @@ export const isRecord = (value: unknown): value is Record<string, unknown> =>
311
311
 
312
312
  export const isCardToolName = (name: string) => name === "card" || name === "cloud_card";
313
313
 
314
+ export const isChartToolName = (name: string) => name === "chart";
315
+
314
316
  export const isSurveyToolName = (name: string) => name === "survey" || name === "cloud_survey";
315
317
 
316
318
  export const isTextEditorToolName = (name: string) => name === "text_editor" || name === "cloud_text_editor";
@@ -326,6 +328,7 @@ export const displayToolName = (name: string, locale: string) => {
326
328
  const BUILT_IN_TOOL_ICONS = new Map<string, string>([
327
329
  ["card", "ti ti-layout-cards"],
328
330
  ["cloud_card", "ti ti-layout-cards"],
331
+ ["chart", "ti ti-chart-bar"],
329
332
  ["survey", "ti ti-forms"],
330
333
  ["cloud_survey", "ti ti-forms"],
331
334
  ["text_editor", "ti ti-edit"],
@@ -9,6 +9,7 @@ const messages = i18n.define({
9
9
  tableLinks: "Links",
10
10
  tableOpen: "Open",
11
11
  tableEmpty: "No matching rows.",
12
+ chartUnavailable: "This chart cannot be shown.",
12
13
  tableCapped: "Showing the first 100 returned rows. The full tool result remains available in details.",
13
14
  tableMore: "More rows are available. Ask for the next page or open the result in the application.",
14
15
  assistantTurns: "Assistant turns",
@@ -84,6 +85,7 @@ const messages = i18n.define({
84
85
  stepDeliveringFile: "Delivering a file",
85
86
  stepPreparingView: "Preparing a view",
86
87
  stepPreparingCard: "Preparing a card",
88
+ stepPreparingChart: "Preparing a chart",
87
89
  stepCompacting: "Summarizing the conversation",
88
90
  stepTool: "Using a tool",
89
91
  stepFailed: "failed",
@@ -98,7 +100,7 @@ const messages = i18n.define({
98
100
  groupLoad: "Loaded tools and guidance",
99
101
  groupFailed: ({ count }: { count: number }) => `${count} failed`,
100
102
  groupRejected: ({ count }: { count: number }) => `${count} rejected`,
101
- receiptDone: ({ title }: { title: string }) => `${title} done`,
103
+ receiptDone: ({ title }: { title: string }) => `Done: ${title}`,
102
104
  receiptApproved: ({ title }: { title: string }) => `Approved: ${title}`,
103
105
  receiptRunning: ({ title }: { title: string }) => `Running: ${title}`,
104
106
  approvalNeeded: ({ title }: { title: string }) => `Approval needed: ${title}`,
@@ -106,6 +108,7 @@ const messages = i18n.define({
106
108
  receiptRejected: ({ title }: { title: string }) => `Rejected: ${title}`,
107
109
  receiptFailed: ({ title }: { title: string }) => `Failed: ${title}`,
108
110
  receiptNotRun: ({ title, stopped }: { title: string; stopped: boolean }) => `Not run: ${title}${stopped ? " · stopped" : ""}`,
111
+ receiptNotRunSentence: ({ sentence, stopped }: { sentence: string; stopped: boolean }) => `${sentence}${stopped ? " · stopped" : ""}`,
109
112
  workSteps: "Work steps",
110
113
  streamLoginRequired: "Your session has ended. Sign in again to continue this chat.",
111
114
  streamAccessDenied: "You no longer have access to this chat.",
@@ -290,6 +293,7 @@ const messages = i18n.define({
290
293
  tableLinks: "Links",
291
294
  tableOpen: "Öffnen",
292
295
  tableEmpty: "Keine passenden Zeilen.",
296
+ chartUnavailable: "Dieses Diagramm kann nicht angezeigt werden.",
293
297
  tableCapped:
294
298
  "Die ersten 100 zurückgegebenen Zeilen werden angezeigt. Das vollständige Werkzeugergebnis bleibt in den Details verfügbar.",
295
299
  tableMore: "Weitere Zeilen sind verfügbar. Frage nach der nächsten Seite oder öffne das Ergebnis in der Anwendung.",
@@ -372,6 +376,7 @@ const messages = i18n.define({
372
376
  stepDeliveringFile: "Stellt eine Datei bereit",
373
377
  stepPreparingView: "Bereitet eine Ansicht vor",
374
378
  stepPreparingCard: "Bereitet eine Karte vor",
379
+ stepPreparingChart: "Bereitet ein Diagramm vor",
375
380
  stepCompacting: "Fasst das Gespräch zusammen",
376
381
  stepTool: "Nutzt ein Werkzeug",
377
382
  stepFailed: "fehlgeschlagen",
@@ -386,7 +391,7 @@ const messages = i18n.define({
386
391
  groupLoad: "Werkzeuge und Wissen geladen",
387
392
  groupFailed: ({ count }) => `${count} fehlgeschlagen`,
388
393
  groupRejected: ({ count }) => `${count} abgelehnt`,
389
- receiptDone: ({ title }) => `${title} erledigt`,
394
+ receiptDone: ({ title }) => `Erledigt: ${title}`,
390
395
  receiptApproved: ({ title }) => `Freigegeben: ${title}`,
391
396
  receiptRunning: ({ title }) => `Wird ausgeführt: ${title}`,
392
397
  approvalNeeded: ({ title }) => `Freigabe nötig: ${title}`,
@@ -394,6 +399,7 @@ const messages = i18n.define({
394
399
  receiptRejected: ({ title }) => `Abgelehnt: ${title}`,
395
400
  receiptFailed: ({ title }) => `Fehlgeschlagen: ${title}`,
396
401
  receiptNotRun: ({ title, stopped }) => `Nicht ausgeführt: ${title}${stopped ? " · gestoppt" : ""}`,
402
+ receiptNotRunSentence: ({ sentence, stopped }) => `${sentence}${stopped ? " · gestoppt" : ""}`,
397
403
  workSteps: "Arbeitsschritte",
398
404
  streamLoginRequired: "Deine Sitzung ist abgelaufen. Melde dich erneut an, um diesen Chat fortzusetzen.",
399
405
  streamAccessDenied: "Du hast keinen Zugriff mehr auf diesen Chat.",
@@ -10,6 +10,7 @@ const toolNames = i18n.define({
10
10
  en: {
11
11
  calculate: "Calculate",
12
12
  card: "Card",
13
+ chart: "Chart",
13
14
  survey: "Survey",
14
15
  text_editor: "Text editor",
15
16
  local_bash: "Local Bash",
@@ -77,6 +78,7 @@ const toolNames = i18n.define({
77
78
  de: {
78
79
  calculate: "Rechnen",
79
80
  card: "Karte",
81
+ chart: "Diagramm",
80
82
  survey: "Umfrage",
81
83
  text_editor: "Texteditor",
82
84
  local_bash: "Lokale Bash",
@@ -1,7 +1,7 @@
1
1
  import { type AiTurnBlock, isRenderableTurnBlock } from "../protocol";
2
2
  import type { AiStoredMessage } from "../types";
3
3
  import { hasCapabilityTable } from "./capability-result";
4
- import { isCardToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
4
+ import { isCardToolName, isChartToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
5
5
  import { isFailedTool } from "./tool-groups";
6
6
 
7
7
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -69,7 +69,7 @@ const isResult = (block: ToolBlock, phase: AiTurnPhase, codePresentations: boole
69
69
  // A running delivery reserves its place once its arguments arrived: a new version of an earlier result must find the
70
70
  // earlier place before it takes one of its own.
71
71
  const delivered = block.status === "completed" || (live(phase) && block.status === "running" && block.args !== undefined);
72
- if (block.name === "present" || isCardToolName(block.name)) return delivered;
72
+ if (block.name === "present" || isCardToolName(block.name) || isChartToolName(block.name)) return delivered;
73
73
  if (block.name === "code_present") return codePresentations && delivered;
74
74
  return hasCapabilityTable(block);
75
75
  };
@@ -17,6 +17,7 @@ import {
17
17
  import { markdown } from "../../shared";
18
18
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
19
19
  import type { AiTurnError } from "../types";
20
+ import { toolOutcome, toolSubject } from "./action-sentences";
20
21
  import {
21
22
  type AiApprovalDecision,
22
23
  ApprovalBlockView,
@@ -26,9 +27,18 @@ import {
26
27
  TextEditorToolView,
27
28
  } from "./blocks";
28
29
  import { CapabilityTablePreview } from "./capability-table";
30
+ import { CloudChartBlock } from "./chart-block";
29
31
  import { PresentToolBlock } from "./file-tools";
30
32
  import { useAiChatActions } from "./message-actions";
31
- import { aiToolIcon, capabilityErrorDescription, displayToolName, isCardToolName, isRecord, isSurveyToolName } from "./message-utils";
33
+ import {
34
+ aiToolIcon,
35
+ capabilityErrorDescription,
36
+ displayToolName,
37
+ isCardToolName,
38
+ isChartToolName,
39
+ isRecord,
40
+ isSurveyToolName,
41
+ } from "./message-utils";
32
42
  import { aiChatMessages } from "./messages";
33
43
  import { AssistantMarkdownBlock } from "./primitives";
34
44
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
@@ -133,6 +143,7 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
133
143
  if (name === "present") return withTarget(t.stepDeliveringFile);
134
144
  if (name === "code_present") return withTarget(t.stepPreparingView);
135
145
  if (isCardToolName(name)) return t.stepPreparingCard;
146
+ if (isChartToolName(name)) return t.stepPreparingChart;
136
147
  if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
137
148
  return withTarget(t.stepWritingCode);
138
149
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
@@ -436,6 +447,9 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
436
447
  <Match when={isCardToolName(current().name)}>
437
448
  <CloudCardBlock args={current().args} />
438
449
  </Match>
450
+ <Match when={isChartToolName(current().name)}>
451
+ <CloudChartBlock args={current().args} completed={current().status === "completed"} />
452
+ </Match>
439
453
  <Match when={true}>
440
454
  <Show when={!props.receipt()}>
441
455
  <CapabilityResultRow block={current()} />
@@ -455,33 +469,37 @@ type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
455
469
 
456
470
  /**
457
471
  * One line for an action in place 4, the same element while it runs and once it is done, rejected, failed, or not run.
458
- * It names the action in the reader's language with what it acted on, such as "Rejected: Run code · report.ts", or the
459
- * application's own summary of what it did.
472
+ * It says what happened in the owning app's words: its sentence for the outcome, such as "Moved report.pdf to the
473
+ * trash", else, once done, the application's own summary of the call. Without either it puts the state in the reader's
474
+ * language before what the call does, such as "Rejected: Write report.md", or before its title and target.
460
475
  */
461
476
  function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
462
477
  const locale = useLocale();
463
478
  const t = () => aiChatMessages(locale());
464
479
  const block = () => props.block;
465
480
  const subject = () => {
481
+ const sentence = toolSubject(block(), locale());
482
+ if (sentence) return sentence;
466
483
  const title = block().presentation?.title ?? displayToolName(block().name, locale());
467
484
  const target = toolTarget(block());
468
485
  return target ? `${title} · ${target}` : title;
469
486
  };
487
+ const capabilityAction = () => block().presentation?.kind === "capability";
470
488
  const links = () => (props.state === "done" ? resultLinks(block()) : []);
471
489
  const label = () => {
472
490
  const state = props.state;
473
491
  if (state === "running") return t().receiptRunning({ title: subject() });
474
492
  if (state === "done") {
475
- const summary = resultSummary(block());
476
- if (summary) return summary;
477
493
  // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
478
- return block().presentation?.kind === "capability"
479
- ? t().receiptDone({ title: subject() })
480
- : t().receiptApproved({ title: subject() });
494
+ if (!capabilityAction()) return t().receiptApproved({ title: subject() });
495
+ return toolOutcome(block(), "done", locale()) || resultSummary(block()) || t().receiptDone({ title: subject() });
481
496
  }
482
- if (state === "rejected") return t().receiptRejected({ title: subject() });
497
+ if (state === "rejected") return toolOutcome(block(), "rejected", locale()) ?? t().receiptRejected({ title: subject() });
483
498
  if (state === "failed") return t().receiptFailed({ title: subject() });
484
- return t().receiptNotRun({ title: subject(), stopped: props.stopped });
499
+ const notRun = toolOutcome(block(), "notRun", locale());
500
+ return notRun
501
+ ? t().receiptNotRunSentence({ sentence: notRun, stopped: props.stopped })
502
+ : t().receiptNotRun({ title: subject(), stopped: props.stopped });
485
503
  };
486
504
  const declined = () => props.state === "rejected" || props.state === "not_run";
487
505
  return (
@@ -529,7 +547,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
529
547
  if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
530
548
  announce(
531
549
  aiChatMessages(locale()).approvalNeeded({
532
- title: current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
550
+ title: toolSubject(current.block, locale()) ?? current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
533
551
  }),
534
552
  );
535
553
  }),
@@ -3,8 +3,10 @@ import { z } from "zod";
3
3
  import { readBoundedJson } from "../_internal/bounded-json";
4
4
  import { getApp } from "../_internal/registry";
5
5
  import type { RequestActor } from "../server";
6
+ import { IDENTITY_REFRESH_TIMEOUT_MS } from "../services/identity/constants";
6
7
  import { signInvocationToken } from "../services/identity/invocation-token";
7
8
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
9
+ import { logger } from "../services/logging";
8
10
  import { LOCALE_HEADER } from "../shared/locale";
9
11
  import { TIMEZONE_COOKIE } from "../shared/time";
10
12
  import { CodeToolFailure } from "./browser-code-contracts";
@@ -13,6 +15,9 @@ import { CODE_CAPABILITY_TOKEN_HEADER, codeCapabilityOperation } from "./code-ca
13
15
  import { authorizeCodeExecution } from "./code-execution";
14
16
  import { aiConversations } from "./store";
15
17
 
18
+ const log = logger("ai:code-runtime");
19
+ const ISSUANCE_TIMEOUT_MS = 5_000;
20
+
16
21
  const Reply = z.object({
17
22
  ok: z.literal(true),
18
23
  data: z.object({
@@ -49,54 +54,64 @@ export const runManagedCodeTool =
49
54
  await context.reportProgress?.(context.locale?.startsWith("de") ? "Ausführungshost verbinden" : "Connecting execution host");
50
55
  const app = await getApp("assistant");
51
56
  if (!app) throw new Error("Assistant code host is unavailable");
57
+ const issue = async (audience: { targetAppId: string; callingAppId: string; operation: string }) => {
58
+ // A token is signed before its request goes out, so a timed-out issuance
59
+ // never reached the host and is safe to repeat. Retry long enough for a
60
+ // stalled shared refresh to finish or fall back to the cached key. Every
61
+ // timeout used up a full attempt or ended a shared load, so this cannot spin.
62
+ const giveUpAt = Date.now() + IDENTITY_REFRESH_TIMEOUT_MS + ISSUANCE_TIMEOUT_MS;
63
+ while (true) {
64
+ try {
65
+ return await withActiveIdentitySigner(
66
+ "invocation",
67
+ (signer) =>
68
+ signInvocationToken({
69
+ ...audience,
70
+ schemaHash: null,
71
+ authority: {
72
+ sub: actor.user.id,
73
+ principal_type: "user",
74
+ access_subject_type: "user",
75
+ access_subject_id: actor.user.id,
76
+ credential_kind: "session",
77
+ scopes: [],
78
+ },
79
+ signer,
80
+ issuer: signer.issuer,
81
+ }),
82
+ { signal: context.signal, timeoutMs: ISSUANCE_TIMEOUT_MS },
83
+ );
84
+ } catch (error) {
85
+ if (context.signal.aborted || !(error instanceof DOMException && error.name === "TimeoutError")) throw error;
86
+ if (Date.now() < giveUpAt) continue;
87
+ log.warn("Code call gave up on identity issuance", { tool: name, error: error.message });
88
+ throw new Error(
89
+ context.locale?.startsWith("de")
90
+ ? "Der Identitätsdienst hat nicht rechtzeitig geantwortet. Prüfe, ob dieser Aufruf schon gestartet ist, bevor du ihn erneut ausführst."
91
+ : "The identity service did not respond in time. Check whether this call already started before running it again.",
92
+ { cause: error },
93
+ );
94
+ }
95
+ }
96
+ };
97
+ // Polls run every 250 ms for as long as the code runs. Reuse both tokens
98
+ // while they stay valid for at least 10 s, so a long run or an approval
99
+ // round trip does not depend on a fresh issuance for every poll.
100
+ const usable = (token: Awaited<ReturnType<typeof signInvocationToken>> | undefined) =>
101
+ token && token.claims.exp * 1000 - Date.now() >= 10_000 ? token : undefined;
102
+ let signed: Awaited<ReturnType<typeof signInvocationToken>> | undefined;
52
103
  let callback: Awaited<ReturnType<typeof signInvocationToken>> | undefined;
53
104
  const request = async (decision?: { id: string; approved: boolean }) => {
54
105
  context.signal.throwIfAborted();
55
106
  await authorizeCodeExecution(context.conversationId!, context.turnId!, actor.user.id);
56
- const signed = await withActiveIdentitySigner(
57
- "invocation",
58
- (signer) =>
59
- signInvocationToken({
60
- targetAppId: "assistant",
61
- callingAppId: "core",
62
- operation: `tool:${name}`,
63
- schemaHash: null,
64
- authority: {
65
- sub: actor.user.id,
66
- principal_type: "user",
67
- access_subject_type: "user",
68
- access_subject_id: actor.user.id,
69
- credential_kind: "session",
70
- scopes: [],
71
- },
72
- signer,
73
- issuer: signer.issuer,
74
- }),
75
- { signal: context.signal, timeoutMs: 5000 },
76
- );
77
- if (!callback || callback.claims.exp * 1000 - Date.now() < 10_000) {
78
- callback = await withActiveIdentitySigner(
79
- "invocation",
80
- (signer) =>
81
- signInvocationToken({
82
- targetAppId: "core",
83
- callingAppId: "assistant",
84
- operation: codeCapabilityOperation(context.conversationId!, context.turnId!),
85
- schemaHash: null,
86
- authority: {
87
- sub: actor.user.id,
88
- principal_type: "user",
89
- access_subject_type: "user",
90
- access_subject_id: actor.user.id,
91
- credential_kind: "session",
92
- scopes: [],
93
- },
94
- signer,
95
- issuer: signer.issuer,
96
- }),
97
- { signal: context.signal, timeoutMs: 5000 },
98
- );
99
- }
107
+ signed = usable(signed) ?? (await issue({ targetAppId: "assistant", callingAppId: "core", operation: `tool:${name}` }));
108
+ callback =
109
+ usable(callback) ??
110
+ (await issue({
111
+ targetAppId: "core",
112
+ callingAppId: "assistant",
113
+ operation: codeCapabilityOperation(context.conversationId!, context.turnId!),
114
+ }));
100
115
  const headers = new Headers({ authorization: `Bearer ${signed.token}`, "content-type": "application/json" });
101
116
  headers.set(CODE_CAPABILITY_TOKEN_HEADER, callback.token);
102
117
  if (context.locale) headers.set(LOCALE_HEADER, context.locale);