@k2b/cloud 0.29.0 → 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 (92) hide show
  1. package/package.json +5 -3
  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 +203 -185
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
  17. package/src/ai/chat/message-utils.ts +3 -0
  18. package/src/ai/chat/messages.ts +12 -26
  19. package/src/ai/chat/tool-disclosure.tsx +21 -14
  20. package/src/ai/chat/tool-groups.ts +64 -37
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +4 -4
  23. package/src/ai/chat/turn-view.tsx +141 -69
  24. package/src/ai/code-runtime-tools.ts +59 -44
  25. package/src/ai/default-tool-contracts.ts +198 -0
  26. package/src/ai/default-tools.ts +25 -1
  27. package/src/ai/executor.ts +19 -21
  28. package/src/ai/index.ts +9 -1
  29. package/src/ai/migrate.ts +59 -12
  30. package/src/ai/protocol.ts +9 -4
  31. package/src/ai/runtime-tools.ts +1 -0
  32. package/src/ai/skill-content.ts +54 -0
  33. package/src/ai/skills-routes.ts +22 -13
  34. package/src/ai/skills.ts +177 -231
  35. package/src/ai/store.ts +20 -13
  36. package/src/ai/tool-sentences.ts +38 -0
  37. package/src/ai/types.ts +10 -2
  38. package/src/api/admin-ai-skills.ts +35 -16
  39. package/src/api/admin-outgoing-mail.ts +18 -0
  40. package/src/api/fanout.ts +128 -0
  41. package/src/api/help.ts +8 -3
  42. package/src/api/mcp.ts +4 -1
  43. package/src/api/search.ts +31 -106
  44. package/src/api/widgets.ts +259 -100
  45. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  46. package/src/browser/ndjson.ts +34 -0
  47. package/src/browser/reload.ts +46 -0
  48. package/src/browser/search-stream.ts +4 -26
  49. package/src/browser/widgets.ts +28 -0
  50. package/src/capabilities/server.ts +20 -2
  51. package/src/cli/access.ts +48 -14
  52. package/src/cli/admin/ai-skills.ts +59 -39
  53. package/src/cli/admin/outgoing-mail.ts +30 -2
  54. package/src/contracts/announcement-cookie.ts +62 -0
  55. package/src/contracts/announcements.ts +14 -52
  56. package/src/contracts/capabilities.ts +24 -0
  57. package/src/contracts/outgoing-mail.ts +56 -11
  58. package/src/contracts/pwa-paths.ts +7 -2
  59. package/src/contracts/pwa.ts +2 -7
  60. package/src/contracts/registry.ts +1 -0
  61. package/src/contracts/shared.ts +5 -0
  62. package/src/contracts/widgets.ts +20 -0
  63. package/src/index.ts +1 -0
  64. package/src/server/help.ts +8 -1
  65. package/src/services/accounts/users.ts +15 -0
  66. package/src/services/help/index.ts +18 -5
  67. package/src/services/help/types.ts +3 -2
  68. package/src/services/identity/constants.ts +2 -0
  69. package/src/services/identity/key-ring.ts +123 -42
  70. package/src/services/identity/runtime-config.ts +13 -2
  71. package/src/services/outbox.ts +24 -9
  72. package/src/services/outgoing-mail/index.ts +33 -5
  73. package/src/services/outgoing-mail/messages.ts +60 -19
  74. package/src/services/outgoing-mail/store.ts +63 -4
  75. package/src/shared/network-address.ts +1 -0
  76. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  77. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  78. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  79. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  80. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  81. package/src/ssr/PwaRuntime.island.tsx +1 -1
  82. package/src/ssr/app-navigation.ts +5 -7
  83. package/src/ssr/global-search-host.tsx +55 -0
  84. package/src/ssr/help.ts +3 -3
  85. package/src/ssr/rail-context.ts +4 -3
  86. package/src/ssr/runtime.ts +14 -1
  87. package/src/styles/effects.css +142 -0
  88. package/src/styles/utilities-markdown-table.css +5 -1
  89. package/src/ai/code-mode-skill.ts +0 -114
  90. package/src/ai/data-analysis-skill.ts +0 -10
  91. package/src/ai/grids-skill.ts +0 -158
  92. 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,
@@ -183,7 +187,14 @@ function ToolResultDisclosure(props: {
183
187
  );
184
188
  }
185
189
 
186
- export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
190
+ /** A decision the server accepted, for the call it answered. */
191
+ export type AiApprovalDecision = { callId: string; approved: boolean };
192
+
193
+ /**
194
+ * The pending approval: a calm tinted card that says what will happen, with its fields and the decision. `onDecided`
195
+ * hears an accepted decision with the call it answered, so the turn can show its receipt in the card's place right away.
196
+ */
197
+ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onDecided?: (decision: AiApprovalDecision) => void }) {
187
198
  const actions = useAiChatActions();
188
199
  const locale = useLocale();
189
200
  const t = () => aiChatMessages(locale());
@@ -192,21 +203,34 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
192
203
  const actionDisabled = () => actions.actionDisabled?.() ?? false;
193
204
  const [submitted, setSubmitted] = createSignal(false);
194
205
  const [detailsOpen, setDetailsOpen] = createSignal(false);
206
+ // The call this card answered: a later approval for the same block may already have arrived when the server accepts.
207
+ let decision: AiApprovalDecision | null = null;
195
208
  const approval = mutation.create<void, { approved: boolean; remember?: "always" }>({
196
209
  mutation: async (input) => {
197
210
  if (!actions.onApproval) throw new Error("Approval is unavailable.");
198
211
  await actions.onApproval(request(), input);
199
212
  },
200
- onSuccess: () => setSubmitted(true),
213
+ onSuccess: () => {
214
+ setSubmitted(true);
215
+ if (decision) props.onDecided?.(decision);
216
+ },
201
217
  });
202
218
  const submit = (input: { approved: boolean; remember?: "always" }) => {
203
- if (!actionDisabled() && !approval.loading()) void approval.mutate(input);
219
+ if (actionDisabled() || approval.loading()) return;
220
+ decision = { callId: props.block.callId, approved: input.approved };
221
+ void approval.mutate(input);
204
222
  };
205
- 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());
206
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);
207
229
  const description = () => {
208
230
  const reviewMessage = props.block.approval?.review?.message.trim();
209
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;
210
234
  const message = props.block.approval?.message?.trim();
211
235
  if (!message) return null;
212
236
  const duplicateTitle = props.block.presentation ? `${props.block.presentation.appName}: ${props.block.presentation.title}` : "";
@@ -239,199 +263,174 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
239
263
  download: t().download,
240
264
  })[rel];
241
265
  const detailData = () => (isStructuredDataValue(props.block.args) ? props.block.args : undefined);
242
- const appAccent = () => props.block.presentation?.appAccent;
243
266
  const detailsId = `approval-details-${props.block.callId}`;
244
267
  return (
245
- <div class="w-full">
246
- <section
247
- class={`w-full overflow-hidden rounded-xl border border-[var(--k2b-border)] bg-[var(--k2b-surface)] text-sm text-primary ${appAccent() ? "app-accent-scope" : ""}`}
248
- style={{ "--app-accent": appAccent() }}
249
- aria-label={t().approvalRequired({ title: title() })}
250
- >
251
- <div class="p-4">
252
- <div class="flex min-w-0 items-center gap-3">
253
- <span
254
- class="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-base leading-none"
255
- style={{
256
- color: appAccent() ? "var(--ui-app-accent-text)" : "var(--k2b-ai-accent)",
257
- background: appAccent()
258
- ? "color-mix(in srgb, var(--app-accent) 10%, var(--k2b-surface))"
259
- : "color-mix(in srgb, var(--k2b-ai-accent) 10%, var(--k2b-surface))",
260
- }}
261
- aria-hidden="true"
262
- >
263
- <i class={`${aiToolIcon(props.block.name, props.block.presentation?.appIcon)} leading-none`} />
264
- </span>
265
- <div class="min-w-0">
266
- <h3 class="truncate text-sm font-semibold leading-5 text-primary">
267
- {ownerName()} · {title()}
268
- </h3>
269
- <p class="text-xs text-dimmed">{t().action}</p>
270
- </div>
271
- </div>
272
- <Show when={reviewLines().length > 0}>
273
- <div class="mt-4 flex flex-col gap-1 text-xs leading-5 text-secondary">
274
- <For each={reviewLines()}>
275
- {(line) => (
276
- <p class="whitespace-pre-wrap">
277
- <Show when={line.label}>{(label) => <strong class="font-semibold text-primary">{label()}: </strong>}</Show>
278
- {line.value}
279
- </p>
280
- )}
281
- </For>
282
- </div>
283
- </Show>
284
- <Show when={props.block.approval?.review}>
285
- <div class="mt-4 flex flex-col gap-4 text-xs leading-5 text-secondary">
286
- <p class="whitespace-pre-wrap">{description()}</p>
287
- <Show when={inlineReviewDetails().length > 0}>
288
- <dl class="grid gap-x-5 gap-y-1.5 sm:grid-cols-[max-content_minmax(0,1fr)]">
289
- <For each={inlineReviewDetails()}>
290
- {(detail) => (
291
- <>
292
- <dt class="font-semibold text-primary">{detail.label}</dt>
293
- <dd class="min-w-0 whitespace-pre-wrap break-words">
294
- <ReviewDetailValue detail={detail} />
295
- </dd>
296
- </>
297
- )}
298
- </For>
299
- </dl>
268
+ <section class="ai-approval" aria-label={t().approvalRequired({ title: title() })}>
269
+ <div class="ai-approval__head">
270
+ <i class={`ai-approval__icon ${aiToolIcon(props.block.name, props.block.presentation?.appIcon)}`} aria-hidden="true" />
271
+ <div class="min-w-0">
272
+ <h3 class="ai-approval__title">{title()}</h3>
273
+ <p class="ai-approval__sub">
274
+ {ownerName()} · {t().approvalRunsAfter}
275
+ </p>
276
+ </div>
277
+ </div>
278
+ <Show when={reviewLines().length > 0}>
279
+ <div class="ai-approval__text">
280
+ <For each={reviewLines()}>
281
+ {(line) => (
282
+ <p class="whitespace-pre-wrap">
283
+ <Show when={line.label}>{(label) => <strong class="ai-approval__label">{label()}: </strong>}</Show>
284
+ {line.value}
285
+ </p>
286
+ )}
287
+ </For>
288
+ </div>
289
+ </Show>
290
+ <Show when={props.block.approval?.review}>
291
+ <p class="ai-approval__text whitespace-pre-wrap">{description()}</p>
292
+ <Show when={inlineReviewDetails().length > 0}>
293
+ <dl class="ai-approval__fields">
294
+ <For each={inlineReviewDetails()}>
295
+ {(detail) => (
296
+ <>
297
+ <dt>{detail.label}</dt>
298
+ <dd class="whitespace-pre-wrap">
299
+ <ReviewDetailValue detail={detail} />
300
+ </dd>
301
+ </>
302
+ )}
303
+ </For>
304
+ </dl>
305
+ </Show>
306
+ <For each={blockReviewDetails()}>
307
+ {(detail) => (
308
+ <section class="ai-approval__text min-w-0" aria-label={detail.label}>
309
+ <h4 class="ai-approval__label mb-1">{detail.label}</h4>
310
+ <Show
311
+ when={detail.format === "markdown"}
312
+ fallback={
313
+ <pre
314
+ class="ai-approval__preview whitespace-pre-wrap break-words font-sans"
315
+ role="region"
316
+ tabIndex={0}
317
+ aria-label={t().contentOf({ label: detail.label })}
318
+ >
319
+ <ReviewDetailValue detail={detail} />
320
+ </pre>
321
+ }
322
+ >
323
+ <div class="ai-approval__preview" role="region" tabIndex={0} aria-label={detail.label}>
324
+ <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
325
+ </div>
300
326
  </Show>
301
- <For each={blockReviewDetails()}>
302
- {(detail) => (
303
- <section class="min-w-0" aria-label={detail.label}>
304
- <h4 class="mb-1.5 font-semibold text-primary">{detail.label}</h4>
305
- <Show
306
- when={detail.format === "markdown"}
307
- fallback={
308
- <pre
309
- class="max-h-72 overflow-auto whitespace-pre-wrap break-words pr-2 font-sans text-xs leading-5 text-secondary"
310
- role="region"
311
- tabIndex={0}
312
- aria-label={t().contentOf({ label: detail.label })}
313
- >
314
- <ReviewDetailValue detail={detail} />
315
- </pre>
316
- }
317
- >
318
- <div
319
- class="-mx-2 max-h-72 overflow-auto px-2 text-xs text-secondary"
320
- role="region"
321
- tabIndex={0}
322
- aria-label={detail.label}
323
- >
324
- <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
325
- </div>
326
- </Show>
327
- </section>
328
- )}
329
- </For>
330
- </div>
327
+ </section>
328
+ )}
329
+ </For>
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>
339
+ <Show when={detailsOpen()}>
340
+ <div id={detailsId} class="w-full min-w-0" role="region" aria-label={t().detailsFor({ title: title() })}>
341
+ <Show when={detailData()} fallback={<pre class="ai-approval__preview text-[11px]">{jsonPreview(props.block.args)}</pre>}>
342
+ {(data) => <StructuredDataPreview data={data()} class="w-full" />}
331
343
  </Show>
332
344
  </div>
333
- {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
334
- <footer class="flex min-h-12 flex-wrap items-center gap-2 px-4 pb-3" data-ai-approval-footer>
335
- {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
336
- <span class="k2b-sr-only" role="status" aria-live="polite">
337
- {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
338
- </span>
339
- <Show when={reviewLinks().length > 0}>
340
- <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
341
- <For each={reviewLinks()}>
342
- {(link) => (
343
- <ButtonLink href={link.href} target="_blank" rel="noopener noreferrer" size="xs" variant="ghost">
344
- {link.title ?? reviewLinkTitle(link.rel)}
345
- </ButtonLink>
346
- )}
347
- </For>
348
- </nav>
349
- </Show>
350
- <Show when={approval.error()}>
351
- <p class="text-xs text-red-700 dark:text-red-300" role="alert">
352
- {t().approvalFailed}
353
- </p>
354
- </Show>
355
- <div class="ml-auto shrink-0">
356
- <Show
357
- when={pending()}
358
- fallback={
359
- <span class="text-xs font-medium text-secondary">
360
- {title()} · {props.block.status === "rejected" ? t().rejected : t().approved}
361
- </span>
362
- }
363
- >
364
- <Show when={actions.onApproval} fallback={<span class="text-xs font-medium text-secondary">{t().approvalUnavailable}</span>}>
345
+ </Show>
346
+ {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
347
+ <footer class="ai-approval__foot" data-ai-approval-footer>
348
+ {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
349
+ <span class="k2b-sr-only" role="status" aria-live="polite">
350
+ {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
351
+ </span>
352
+ <Show when={reviewLinks().length > 0}>
353
+ <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
354
+ <For each={reviewLinks()}>
355
+ {(link) => (
356
+ <ButtonLink href={link.href} target="_blank" rel="noopener noreferrer" size="xs" variant="ghost">
357
+ {link.title ?? reviewLinkTitle(link.rel)}
358
+ </ButtonLink>
359
+ )}
360
+ </For>
361
+ </nav>
362
+ </Show>
363
+ <Show when={approval.error()}>
364
+ <p class="text-xs text-red-700 dark:text-red-300" role="alert">
365
+ {t().approvalFailed}
366
+ </p>
367
+ </Show>
368
+ <div class="ai-approval__actions">
369
+ <Show
370
+ when={pending()}
371
+ fallback={
372
+ <span class="ai-approval__state">
373
+ {title()} · {props.block.status === "rejected" ? t().rejected : t().approved}
374
+ </span>
375
+ }
376
+ >
377
+ <Show when={actions.onApproval} fallback={<span class="ai-approval__state">{t().approvalUnavailable}</span>}>
378
+ <Show
379
+ when={!actionDisabled()}
380
+ fallback={
381
+ <span class="ai-approval__state">
382
+ <i class="ti ti-player-stop" aria-hidden="true" />
383
+ {t().stoppingResponse}
384
+ </span>
385
+ }
386
+ >
365
387
  <Show
366
- when={!actionDisabled()}
388
+ when={!submitted()}
367
389
  fallback={
368
- <span class="inline-flex items-center gap-1 text-xs font-medium text-secondary">
369
- <i class="ti ti-player-stop" aria-hidden="true" />
370
- {t().stoppingResponse}
390
+ <span class="ai-approval__state">
391
+ <i class="ti ti-check" aria-hidden="true" />
392
+ {t().submitted}
371
393
  </span>
372
394
  }
373
395
  >
374
- <Show
375
- when={!approval.loading() && !submitted()}
376
- fallback={
377
- <span class="inline-flex items-center gap-1 text-xs font-medium text-secondary">
378
- <i class={`ti ${submitted() ? "ti-check" : "ti-loader-2 animate-spin"}`} aria-hidden="true" />
379
- {submitted() ? t().submitted : t().submitting}
380
- </span>
381
- }
396
+ {/* The buttons stay while the decision is sent, so the card does not change its height before it
397
+ becomes its receipt. */}
398
+ <Button size="sm" variant="ghost" disabled={approval.loading()} onClick={() => submit({ approved: false })}>
399
+ {t().reject}
400
+ </Button>
401
+ <SplitButton
402
+ size="sm"
403
+ variant="ai"
404
+ loading={approval.loading()}
405
+ onClick={() => submit({ approved: true })}
406
+ menuLabel={t().moreOptions({ title: title() })}
407
+ menuPosition="bottom-right"
408
+ items={[
409
+ {
410
+ label: detailsOpen() ? t().hideDetails : t().details,
411
+ icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
412
+ action: () => setDetailsOpen((open) => !open),
413
+ },
414
+ ...(props.block.approval?.allowAlways
415
+ ? [
416
+ {
417
+ label: t().alwaysApprove,
418
+ icon: "ti ti-shield-check",
419
+ action: () => submit({ approved: true, remember: "always" }),
420
+ },
421
+ ]
422
+ : []),
423
+ ]}
382
424
  >
383
- <div class="flex flex-wrap justify-end gap-1">
384
- <Button size="xs" variant="ghost" onClick={() => submit({ approved: false })}>
385
- {t().reject}
386
- </Button>
387
- <SplitButton
388
- size="xs"
389
- variant="ai"
390
- onClick={() => submit({ approved: true })}
391
- menuLabel={t().moreOptions({ title: title() })}
392
- menuPosition="bottom-right"
393
- items={[
394
- {
395
- label: detailsOpen() ? t().hideDetails : t().details,
396
- icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
397
- action: () => setDetailsOpen((open) => !open),
398
- },
399
- ...(props.block.approval?.allowAlways
400
- ? [
401
- {
402
- label: t().alwaysApprove,
403
- icon: "ti ti-shield-check",
404
- action: () => submit({ approved: true, remember: "always" }),
405
- },
406
- ]
407
- : []),
408
- ]}
409
- >
410
- {title()}
411
- </SplitButton>
412
- </div>
413
- </Show>
425
+ {title()}
426
+ </SplitButton>
414
427
  </Show>
415
428
  </Show>
416
429
  </Show>
417
- </div>
418
- </footer>
419
- </section>
420
- <Show when={detailsOpen()}>
421
- <div id={detailsId} class="mt-2 w-full" role="region" aria-label={t().detailsFor({ title: title() })}>
422
- <Show
423
- when={detailData()}
424
- fallback={
425
- <pre class="max-h-40 overflow-auto rounded-md bg-white/55 p-2 text-[11px] text-primary dark:bg-black/20">
426
- {jsonPreview(props.block.args)}
427
- </pre>
428
- }
429
- >
430
- {(data) => <StructuredDataPreview data={data()} class="w-full" />}
431
430
  </Show>
432
431
  </div>
433
- </Show>
434
- </div>
432
+ </footer>
433
+ </section>
435
434
  );
436
435
  }
437
436
 
@@ -537,11 +536,11 @@ function CapabilityToolView(props: { block: ToolBlock }) {
537
536
  function RejectedToolView(props: { block: ToolBlock }) {
538
537
  const locale = useLocale();
539
538
  const presentation = () => props.block.presentation;
540
- const title = () => presentation()?.title ?? displayToolName(props.block.name, locale());
539
+ const title = () => toolSubject(props.block, locale()) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
541
540
  return (
542
541
  <Chat.Activity
543
542
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
544
- label={aiChatMessages(locale()).toolRejected({ title: title() })}
543
+ label={toolOutcome(props.block, "rejected", locale()) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
545
544
  accent={presentation()?.appAccent}
546
545
  />
547
546
  );
@@ -713,6 +712,9 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
713
712
  <Match when={isCardToolName(props.block.name) && !props.block.isError}>
714
713
  <CloudCardBlock args={props.block.args} />
715
714
  </Match>
715
+ <Match when={isChartToolName(props.block.name) && !props.block.isError}>
716
+ <CloudChartBlock args={props.block.args} completed={status() === "completed"} />
717
+ </Match>
716
718
  <Match when={isSurveyToolName(props.block.name) && !props.block.isError}>
717
719
  <SurveyToolView turnId={props.turnId} block={props.block} active={props.active} />
718
720
  </Match>
@@ -810,7 +812,22 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
810
812
  const locale = useLocale();
811
813
  const t = () => aiChatMessages(locale());
812
814
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
813
- 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");
814
831
  const waiting = () => props.block.status === "awaiting_client" || props.block.status === "awaiting_approval";
815
832
  const state = () =>
816
833
  waiting() ? t().stepWaiting : props.block.status === "rejected" ? t().stepRejected : isFailedTool(props.block) ? t().stepFailed : "";
@@ -838,7 +855,7 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
838
855
  return (
839
856
  <AiToolActivity
840
857
  blockId={props.block.id}
841
- label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
858
+ label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
842
859
  description={[
843
860
  props.block.status === "running" ? (props.block.progress ?? "") : "",
844
861
  typeof detail() === "string" ? String(detail()) : "",
@@ -849,6 +866,7 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
849
866
  .join(" · ")}
850
867
  icon={icon()}
851
868
  busy={props.busy && props.block.status === "running"}
869
+ bodyInset={false}
852
870
  renderBody={() => (
853
871
  <div class="flex min-w-0 flex-col gap-2">
854
872
  <div
@@ -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
+ }
@@ -11,15 +11,18 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
11
11
  * controller does. `window.emit(event)` applies one event,
12
12
  * `window.steer(text)` appends a pending steer as the controller does,
13
13
  * `window.reconnect(on)` sets whether the stream reconnects,
14
- * `window.continued` lists the messages that **Continue** sent, and
14
+ * `window.continued` lists the messages that **Continue** sent,
15
+ * `window.approvals` lists the approval decisions, and
15
16
  * `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
16
17
  */
17
18
  const [state, setState] = createStore(emptyProjection());
18
19
  const [reconnecting, setReconnecting] = createSignal(false);
19
20
  const continued: string[] = [];
20
21
  const opened: string[] = [];
22
+ const approvals: unknown[] = [];
21
23
  Object.assign(window, {
22
24
  continued,
25
+ approvals,
23
26
  opened,
24
27
  reconnect: (on: boolean) => setReconnecting(on),
25
28
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
@@ -38,6 +41,7 @@ render(
38
41
  <AiChatActionsProvider
39
42
  actions={{
40
43
  onContinueTurn: (message) => void continued.push(message),
44
+ onApproval: async (request, input) => void approvals.push({ callId: request.callId, ...input }),
41
45
  fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
42
46
  onOpenFile: (path) => void opened.push(path),
43
47
  }}
@@ -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"],