@k2b/cloud 0.30.0 → 0.32.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 (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
@@ -1,3 +1,4 @@
1
+ import { createContext, useContext } from "solid-js";
1
2
  import {
2
3
  type CapabilityActionWording,
3
4
  capabilityActionOutcome,
@@ -10,9 +11,14 @@ import { displayToolName, isRecord } from "./message-utils";
10
11
 
11
12
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
12
13
 
13
- /** Instants show in the reader's own time zone; outside a browser they stay in UTC. */
14
- const readerTimeZone = (): string | undefined =>
15
- typeof window === "undefined" ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone;
14
+ const ReaderTimeZoneContext = createContext<string>();
15
+
16
+ /** Gives the chat the reader's time zone from the request, so server and browser render the same times. */
17
+ export const AiReaderTimeZoneProvider = ReaderTimeZoneContext.Provider;
18
+
19
+ /** Instants show in the provided time zone, else the browser's own; outside a browser they stay in UTC. */
20
+ export const useReaderTimeZone = (): string =>
21
+ useContext(ReaderTimeZoneContext) ?? (typeof window === "undefined" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone);
16
22
 
17
23
  /**
18
24
  * How the chat words one call that acts on something: an app Action through its saved presentation, a
@@ -33,17 +39,17 @@ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionW
33
39
  };
34
40
 
35
41
  /** What the call does or would do, as one sentence; `null` for calls that only read. */
36
- export const toolSubject = (block: ToolBlock, locale: string): string | null => {
42
+ export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
37
43
  const wording = toolWording(block, locale);
38
- return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone: readerTimeZone() }) : null;
44
+ return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
39
45
  };
40
46
 
41
47
  /** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
42
- export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string): string | null => {
48
+ export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
43
49
  const wording = toolWording(block, locale);
44
50
  if (!wording) return null;
45
51
  const result = isRecord(block.result) ? block.result : undefined;
46
52
  // App Actions return their data inside the result envelope; Cloud tools return the data itself.
47
53
  const data = block.presentation?.kind === "capability" ? result?.data : block.result;
48
- return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone: readerTimeZone() });
54
+ return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
49
55
  };
@@ -6,7 +6,7 @@ import { capabilityApprovalReason, capabilityApprovalReasonLabel } from "../../_
6
6
  import type { CapabilityActionReview } from "../../contracts/capabilities";
7
7
  import { markdown } from "../../shared";
8
8
  import type { AiTurnBlock } from "../protocol";
9
- import { toolOutcome, toolSubject } from "./action-sentences";
9
+ import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
10
10
  import { hasSpecializedBuiltinToolView, SpecializedBuiltinToolBlock } from "./builtin-tools";
11
11
  import { hasCapabilityTable } from "./capability-result";
12
12
  import { CapabilityTablePreview } from "./capability-table";
@@ -34,7 +34,7 @@ import { AssistantMarkdownBlock } from "./primitives";
34
34
  import { AiToolActivity } from "./tool-disclosure";
35
35
  import { isFailedTool } from "./tool-groups";
36
36
  import { CloudCardBlock, CloudSurveyBlock, CloudSurveyResultBlock, CloudTextEditorBlock, CloudTextEditorResultBlock } from "./visual-tools";
37
- import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock } from "./web-tools";
37
+ import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock, WebsiteReceipts, websiteReceipts } from "./web-tools";
38
38
 
39
39
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
40
40
  type ReviewDetail = NonNullable<CapabilityActionReview["details"]>[number];
@@ -197,6 +197,7 @@ export type AiApprovalDecision = { callId: string; approved: boolean };
197
197
  export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onDecided?: (decision: AiApprovalDecision) => void }) {
198
198
  const actions = useAiChatActions();
199
199
  const locale = useLocale();
200
+ const timeZone = useReaderTimeZone();
200
201
  const t = () => aiChatMessages(locale());
201
202
  const request = () => ({ turnId: props.turnId, callId: props.block.callId, name: props.block.name });
202
203
  const pending = () => props.block.status === "awaiting_approval";
@@ -205,7 +206,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
205
206
  const [detailsOpen, setDetailsOpen] = createSignal(false);
206
207
  // The call this card answered: a later approval for the same block may already have arrived when the server accepts.
207
208
  let decision: AiApprovalDecision | null = null;
208
- const approval = mutation.create<void, { approved: boolean; remember?: "always" }>({
209
+ const approval = mutation.create<void, { approved: boolean; remember?: "chat" | "always" }>({
209
210
  mutation: async (input) => {
210
211
  if (!actions.onApproval) throw new Error("Approval is unavailable.");
211
212
  await actions.onApproval(request(), input);
@@ -215,13 +216,14 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
215
216
  if (decision) props.onDecided?.(decision);
216
217
  },
217
218
  });
218
- const submit = (input: { approved: boolean; remember?: "always" }) => {
219
+ const submit = (input: { approved: boolean; remember?: "chat" | "always" }) => {
219
220
  if (actionDisabled() || approval.loading()) return;
220
221
  decision = { callId: props.block.callId, approved: input.approved };
221
222
  void approval.mutate(input);
222
223
  };
223
224
  // 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());
225
+ const title = () =>
226
+ toolSubject(props.block, locale(), timeZone) ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale());
225
227
  const ownerName = () => props.block.presentation?.appName ?? t().assistant;
226
228
  const capability = () => props.block.presentation?.kind === "capability";
227
229
  // The model's own reason, labelled as such, only where Cloud offered it; it adds to the app's sentence and never replaces it.
@@ -411,6 +413,16 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
411
413
  icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
412
414
  action: () => setDetailsOpen((open) => !open),
413
415
  },
416
+ // The narrower chat reach comes first: it ends with this chat.
417
+ ...(props.block.approval?.allowChat
418
+ ? [
419
+ {
420
+ label: props.block.approval.website ? t().allowWebsiteForChat : t().chatApprove,
421
+ icon: props.block.approval.website ? "ti ti-world-check" : "ti ti-message-check",
422
+ action: () => submit({ approved: true, remember: "chat" }),
423
+ },
424
+ ]
425
+ : []),
414
426
  ...(props.block.approval?.allowAlways
415
427
  ? [
416
428
  {
@@ -535,12 +547,13 @@ function CapabilityToolView(props: { block: ToolBlock }) {
535
547
 
536
548
  function RejectedToolView(props: { block: ToolBlock }) {
537
549
  const locale = useLocale();
550
+ const timeZone = useReaderTimeZone();
538
551
  const presentation = () => props.block.presentation;
539
- const title = () => toolSubject(props.block, locale()) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
552
+ const title = () => toolSubject(props.block, locale(), timeZone) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
540
553
  return (
541
554
  <Chat.Activity
542
555
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
543
- label={toolOutcome(props.block, "rejected", locale()) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
556
+ label={toolOutcome(props.block, "rejected", locale(), timeZone) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
544
557
  accent={presentation()?.appAccent}
545
558
  />
546
559
  );
@@ -725,6 +738,9 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
725
738
  <Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
726
739
  </Match>
727
740
  </Switch>
741
+ <Show when={websiteReceipts(props.block).length > 0}>
742
+ <WebsiteReceipts block={props.block} />
743
+ </Show>
728
744
  <Show when={hasCapabilityTable(props.block)}>
729
745
  <CapabilityTablePreview
730
746
  result={props.block.result}
@@ -810,6 +826,7 @@ function StepImage(props: { path: string; src: string; description: string }) {
810
826
  */
811
827
  export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
812
828
  const locale = useLocale();
829
+ const timeZone = useReaderTimeZone();
813
830
  const t = () => aiChatMessages(locale());
814
831
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
815
832
  /**
@@ -820,11 +837,11 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
820
837
  const status = props.block.status;
821
838
  const outcome =
822
839
  status === "completed" && !props.block.isError
823
- ? toolOutcome(props.block, "done", locale())
840
+ ? toolOutcome(props.block, "done", locale(), timeZone)
824
841
  : status === "rejected"
825
- ? toolOutcome(props.block, "rejected", locale())
842
+ ? toolOutcome(props.block, "rejected", locale(), timeZone)
826
843
  : null;
827
- return outcome ?? toolSubject(props.block, locale());
844
+ return outcome ?? toolSubject(props.block, locale(), timeZone);
828
845
  };
829
846
  const detail = () =>
830
847
  sentence() ? undefined : [args().path, args().name, args().query, args().title].find((value) => typeof value === "string");
@@ -853,35 +870,43 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
853
870
  return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
854
871
  };
855
872
  return (
856
- <AiToolActivity
857
- blockId={props.block.id}
858
- label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
859
- description={[
860
- props.block.status === "running" ? (props.block.progress ?? "") : "",
861
- typeof detail() === "string" ? String(detail()) : "",
862
- state(),
863
- outcome(),
864
- ]
865
- .filter(Boolean)
866
- .join(" · ")}
867
- icon={icon()}
868
- busy={props.busy && props.block.status === "running"}
869
- bodyInset={false}
870
- renderBody={() => (
871
- <div class="flex min-w-0 flex-col gap-2">
872
- <div
873
- class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
874
- tabIndex={0}
875
- role="region"
876
- aria-label={t().toolInputOutput}
877
- >
878
- <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
879
- <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
873
+ <>
874
+ <AiToolActivity
875
+ blockId={props.block.id}
876
+ label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
877
+ description={[
878
+ props.block.status === "running" ? (props.block.progress ?? "") : "",
879
+ typeof detail() === "string" ? String(detail()) : "",
880
+ state(),
881
+ outcome(),
882
+ ]
883
+ .filter(Boolean)
884
+ .join(" · ")}
885
+ icon={icon()}
886
+ busy={props.busy && props.block.status === "running"}
887
+ bodyInset={false}
888
+ renderBody={() => (
889
+ <div class="flex min-w-0 flex-col gap-2">
890
+ <div
891
+ class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
892
+ tabIndex={0}
893
+ role="region"
894
+ aria-label={t().toolInputOutput}
895
+ >
896
+ <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
897
+ <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
898
+ </div>
899
+ {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
900
+ <Show when={image()}>
901
+ {(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}
902
+ </Show>
880
903
  </div>
881
- {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
882
- <Show when={image()}>{(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}</Show>
883
- </div>
884
- )}
885
- />
904
+ )}
905
+ />
906
+ {/* Each request a website approval let through is a step of its own, with its full URL. */}
907
+ <Show when={websiteReceipts(props.block).length > 0}>
908
+ <WebsiteReceipts block={props.block} />
909
+ </Show>
910
+ </>
886
911
  );
887
912
  }
@@ -161,13 +161,15 @@ function LoadToolsView(props: { block: ToolBlock }) {
161
161
  };
162
162
  const t = useMessages();
163
163
  const unavailableLabel = (reason: unknown) =>
164
- reason === "not_offered_in_turn"
165
- ? t().toolNotOffered
166
- : reason === "not_allowed"
167
- ? t().toolNotAllowed
168
- : reason === "app_offline"
169
- ? t().toolAppOffline
170
- : t().toolUnknown;
164
+ reason === "skill"
165
+ ? t().toolIsSkill
166
+ : reason === "not_offered_in_turn"
167
+ ? t().toolNotOffered
168
+ : reason === "not_allowed"
169
+ ? t().toolNotAllowed
170
+ : reason === "app_offline"
171
+ ? t().toolAppOffline
172
+ : t().toolUnknown;
171
173
  const rows = () => [
172
174
  ...toolNames(result().loaded).map((name) => ({ name, label: t().toolsLoaded })),
173
175
  ...toolNames(result().alreadyLoaded).map((name) => ({ name, label: t().toolsAlreadyLoaded })),
@@ -1,13 +1,26 @@
1
- import { ChartExplorer, type ChartExplorerColumn, prepareChartSnapshot, useLocale } from "@k2b/ui";
2
- import { createMemo, Show } from "solid-js";
1
+ import {
2
+ ChartExplorer,
3
+ type ChartExplorerColumn,
4
+ type ChartLegendEntry,
5
+ type ChartSnapshot,
6
+ createChartCursor,
7
+ prepareChartSnapshot,
8
+ useLocale,
9
+ } from "@k2b/ui";
10
+ import { createMemo, createUniqueId, Index, type JSX, Show } from "solid-js";
3
11
  import {
4
12
  type CloudAiChartColumn,
5
13
  type CloudAiChartRow,
14
+ type CloudAiChartTable,
15
+ cloudAiChartLegend,
6
16
  cloudAiChartMarkKey,
17
+ cloudAiChartReferenceMark,
7
18
  cloudAiChartRenderOptions,
8
19
  cloudAiChartRows,
20
+ cloudAiChartXValueFormat,
9
21
  } from "../chart-block";
10
- import { parseCloudAiChartInput } from "../default-tool-contracts";
22
+ import type { CloudAiChartInput } from "../default-tool-contracts";
23
+ import { type CloudAiGroupView, parseCloudAiChartToolView } from "../view-block";
11
24
  import { aiChatMessages } from "./messages";
12
25
 
13
26
  const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
@@ -19,61 +32,222 @@ const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
19
32
  };
20
33
  };
21
34
 
35
+ type Kind = CloudAiChartInput["kind"];
36
+ type DrawnChart = { input: CloudAiChartInput; table: CloudAiChartTable; snapshot: ChartSnapshot; legend: ChartLegendEntry[] };
37
+
38
+ /** Draws a chart for display and fills its table from that drawing; null when nothing can be drawn. */
39
+ const drawChart = (input: CloudAiChartInput, locale: string): DrawnChart | null => {
40
+ try {
41
+ const rows = cloudAiChartRows(input, locale);
42
+ // The chat lays the legend out in HTML: the renderer's single SVG row runs out of a phone-wide column.
43
+ const snapshot = prepareChartSnapshot(cloudAiChartRenderOptions(input, locale, { legend: "outside" }), {
44
+ key: ({ datum }) => cloudAiChartMarkKey(datum),
45
+ tooltip: ({ datum }) => {
46
+ const row = rows.add(datum);
47
+ return tooltip(rows.columns(), row);
48
+ },
49
+ reference: ({ datum }) => cloudAiChartReferenceMark(input, datum),
50
+ });
51
+ return snapshot.marks.length > 0 ? { input, table: rows.table(), snapshot, legend: cloudAiChartLegend(input, locale) } : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
22
57
  /**
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.
58
+ * Single values and trends need less room than a plot with axes. Bar gauges hold at most eight 28-unit rows of the
59
+ * 280-unit drawing; at 12rem a row is still taller than its text.
60
+ */
61
+ const chartHeight = (kind: Kind) => (kind === "sparkline" ? "8rem" : kind === "stat" ? "10rem" : kind === "barGauge" ? "12rem" : "18rem");
62
+
63
+ function ChartFrame(props: {
64
+ title: string;
65
+ subtitle?: string;
66
+ kind: Kind;
67
+ chart: DrawnChart | null;
68
+ empty?: JSX.Element;
69
+ cursor?: ReturnType<typeof createChartCursor>;
70
+ /** A chart of a titled group: its title sits one heading level and one size below the group's. */
71
+ nested?: boolean;
72
+ }) {
73
+ const locale = useLocale();
74
+ const columns = () =>
75
+ props.chart
76
+ ? props.chart.table.columns.map(
77
+ (column): ChartExplorerColumn<CloudAiChartRow> => ({
78
+ id: column.id,
79
+ label: column.label,
80
+ value: (row) => row.cells[column.id] ?? "",
81
+ sortValue: (row) => row.values[column.id] ?? null,
82
+ align: column.numeric ? "right" : "left",
83
+ }),
84
+ )
85
+ : // A frame without a drawing keeps the explorer's header and height; its one column is never shown.
86
+ [{ id: "value", label: aiChatMessages(locale()).chartUnavailable, value: () => "" }];
87
+ return (
88
+ <ChartExplorer
89
+ title={props.title}
90
+ headingLevel={props.nested ? 4 : 3}
91
+ description={props.subtitle ? <span class="text-sm text-secondary">{props.subtitle}</span> : undefined}
92
+ height={chartHeight(props.kind)}
93
+ data={props.chart ? { chart: props.chart.snapshot, rows: props.chart.table.rows } : { chart: emptySnapshot(props.kind), rows: [] }}
94
+ columns={columns()}
95
+ legend={props.chart?.legend.length ? props.chart.legend : undefined}
96
+ cursor={props.cursor}
97
+ empty={props.empty}
98
+ renderDetails={false}
99
+ class={props.nested ? "[&_h4]:text-sm!" : undefined}
100
+ />
101
+ );
102
+ }
103
+
104
+ const emptySnapshot = (kind: Kind): ChartSnapshot => ({
105
+ kind,
106
+ svg: "",
107
+ width: 480,
108
+ height: 280,
109
+ stretch: true,
110
+ marks: [],
111
+ });
112
+
113
+ type GroupSummary = {
114
+ title: string;
115
+ subtitle?: string;
116
+ linkX: boolean;
117
+ layout: "stack" | "grid";
118
+ charts: { kind: Kind; title: string; subtitle?: string }[];
119
+ };
120
+
121
+ /**
122
+ * A group of small charts under one heading. Every chart frame stands with the title and height of its summary from
123
+ * the first frame, while the group loads (`group` undefined) and when it cannot be drawn (`group` null); a chart that
124
+ * cannot be drawn alone shows unavailable in its own frame. With `linkX`, one cursor follows the pointer or a tap
125
+ * across every chart. A grid puts two charts side by side once the chat column has room for both.
126
+ */
127
+ function ChartGroupFrame(props: { summary: GroupSummary; group: CloudAiGroupView | null | undefined; empty?: JSX.Element }) {
128
+ const locale = useLocale();
129
+ const t = () => aiChatMessages(locale());
130
+ const id = `cloud-chart-group-${createUniqueId()}`;
131
+ const drawn = createMemo(() => props.group?.charts.map((input) => drawChart(input, locale())) ?? []);
132
+ // One cursor per group, owned by the component; the first chart's x format titles the shared tooltip.
133
+ const cursor = createChartCursor({
134
+ formatX: (x) => {
135
+ const first = props.group?.charts[0];
136
+ return first ? cloudAiChartXValueFormat(first, locale())(x) : String(x);
137
+ },
138
+ });
139
+ return (
140
+ <section class="@container grid min-w-0 gap-3" aria-labelledby={id}>
141
+ <header class="min-w-0">
142
+ <h3 id={id} class="m-0 text-base font-semibold">
143
+ {props.group?.title ?? props.summary.title}
144
+ </h3>
145
+ <Show when={props.group ? props.group.subtitle : props.summary.subtitle}>
146
+ {(subtitle) => <span class="text-sm text-secondary">{subtitle()}</span>}
147
+ </Show>
148
+ </header>
149
+ <div class={`grid min-w-0 gap-6 ${props.summary.layout === "grid" ? "@2xl:grid-cols-2" : ""}`}>
150
+ <Index each={props.summary.charts}>
151
+ {(chart, index) => (
152
+ <ChartFrame
153
+ title={props.group?.charts[index]?.title ?? chart().title}
154
+ subtitle={props.group ? props.group.charts[index]?.subtitle : chart().subtitle}
155
+ kind={chart().kind}
156
+ chart={drawn()[index] ?? null}
157
+ cursor={props.summary.linkX ? cursor : undefined}
158
+ nested
159
+ empty={props.empty ?? <span role="status">{t().chartUnavailable}</span>}
160
+ />
161
+ )}
162
+ </Index>
163
+ </div>
164
+ </section>
165
+ );
166
+ }
167
+
168
+ const groupSummary = (group: CloudAiGroupView): GroupSummary => ({
169
+ title: group.title,
170
+ subtitle: group.subtitle,
171
+ linkX: group.linkX ?? false,
172
+ layout: group.layout ?? "stack",
173
+ charts: group.charts.map((chart) => ({ kind: chart.kind, title: chart.title, subtitle: chart.subtitle })),
174
+ });
175
+
176
+ /**
177
+ * One `chart` tool call: the shared chart with its data table and Copy data, or a group of them. The block takes its
178
+ * full size with its first frame and keeps it; switching to the table scrolls inside the same height. A call whose
179
+ * arguments are not a valid chart shows nothing while it runs, because the model gets the validation error and the
180
+ * call fails.
26
181
  */
27
182
  export function CloudChartBlock(props: { args: unknown; completed: boolean }) {
28
183
  const locale = useLocale();
29
184
  // Live updates replace the block with equal arguments; only a real change redraws.
30
185
  const source = createMemo(() => JSON.stringify(props.args ?? null));
186
+ const view = createMemo(() => parseCloudAiChartToolView(JSON.parse(source())));
187
+ // The tool schema drew the chart once to accept it; here it is drawn once more, for display, and the table is
188
+ // filled from that drawing.
31
189
  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
- }
190
+ const value = view();
191
+ return value?.view === "chart" ? drawChart(value, locale()) : null;
192
+ });
193
+ const group = createMemo(() => {
194
+ const value = view();
195
+ return value?.view === "group" ? value : undefined;
49
196
  });
50
197
  return (
51
198
  <Show
52
- when={chart()}
199
+ when={group()}
53
200
  fallback={
54
- <Show when={props.completed}>
55
- <p class="text-sm text-secondary">{aiChatMessages(locale()).chartUnavailable}</p>
201
+ <Show
202
+ when={chart()}
203
+ fallback={
204
+ <Show when={props.completed}>
205
+ <p class="text-sm text-secondary">{aiChatMessages(locale()).chartUnavailable}</p>
206
+ </Show>
207
+ }
208
+ >
209
+ {(value) => (
210
+ <ChartFrame title={value().input.title} subtitle={value().input.subtitle} kind={value().input.kind} chart={value()} />
211
+ )}
56
212
  </Show>
57
213
  }
58
214
  >
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
- )}
215
+ {(value) => <ChartGroupFrame summary={groupSummary(value())} group={value()} />}
77
216
  </Show>
78
217
  );
79
218
  }
219
+
220
+ /**
221
+ * A chart view file: the frame stands with the title, actions and height of its `present` summary from the first frame,
222
+ * while the file loads (`input` undefined) and when it cannot be drawn (`input` null).
223
+ */
224
+ export function CloudChartView(props: {
225
+ summary: { title: string; subtitle?: string; kind: Kind };
226
+ input: CloudAiChartInput | null | undefined;
227
+ }) {
228
+ const locale = useLocale();
229
+ const t = () => aiChatMessages(locale());
230
+ const chart = createMemo(() => (props.input ? drawChart(props.input, locale()) : null));
231
+ return (
232
+ <ChartFrame
233
+ title={chart()?.input.title ?? props.summary.title}
234
+ subtitle={chart() ? chart()?.input.subtitle : props.summary.subtitle}
235
+ kind={props.summary.kind}
236
+ chart={chart()}
237
+ empty={<span role="status">{props.input === undefined ? t().viewLoading : t().viewUnavailable}</span>}
238
+ />
239
+ );
240
+ }
241
+
242
+ /** A group view file, sized by its `present` summary like a chart view. */
243
+ export function CloudChartGroupView(props: { summary: GroupSummary; group: CloudAiGroupView | null | undefined }) {
244
+ const locale = useLocale();
245
+ const t = () => aiChatMessages(locale());
246
+ return (
247
+ <ChartGroupFrame
248
+ summary={props.summary}
249
+ group={props.group}
250
+ empty={props.group ? undefined : <span role="status">{props.group === undefined ? t().viewLoading : t().viewUnavailable}</span>}
251
+ />
252
+ );
253
+ }