@k2b/cloud 0.29.1 → 0.31.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 (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. 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,
@@ -30,7 +34,7 @@ import { AssistantMarkdownBlock } from "./primitives";
30
34
  import { AiToolActivity } from "./tool-disclosure";
31
35
  import { isFailedTool } from "./tool-groups";
32
36
  import { CloudCardBlock, CloudSurveyBlock, CloudSurveyResultBlock, CloudTextEditorBlock, CloudTextEditorResultBlock } from "./visual-tools";
33
- import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock } from "./web-tools";
37
+ import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock, WebsiteReceipts, websiteReceipts } from "./web-tools";
34
38
 
35
39
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
36
40
  type ReviewDetail = NonNullable<CapabilityActionReview["details"]>[number];
@@ -201,7 +205,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
201
205
  const [detailsOpen, setDetailsOpen] = createSignal(false);
202
206
  // The call this card answered: a later approval for the same block may already have arrived when the server accepts.
203
207
  let decision: AiApprovalDecision | null = null;
204
- const approval = mutation.create<void, { approved: boolean; remember?: "always" }>({
208
+ const approval = mutation.create<void, { approved: boolean; remember?: "chat" | "always" }>({
205
209
  mutation: async (input) => {
206
210
  if (!actions.onApproval) throw new Error("Approval is unavailable.");
207
211
  await actions.onApproval(request(), input);
@@ -211,16 +215,22 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
211
215
  if (decision) props.onDecided?.(decision);
212
216
  },
213
217
  });
214
- const submit = (input: { approved: boolean; remember?: "always" }) => {
218
+ const submit = (input: { approved: boolean; remember?: "chat" | "always" }) => {
215
219
  if (actionDisabled() || approval.loading()) return;
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>}>
@@ -393,6 +411,16 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
393
411
  icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
394
412
  action: () => setDetailsOpen((open) => !open),
395
413
  },
414
+ // The narrower chat reach comes first: it ends with this chat.
415
+ ...(props.block.approval?.allowChat
416
+ ? [
417
+ {
418
+ label: props.block.approval.website ? t().allowWebsiteForChat : t().chatApprove,
419
+ icon: props.block.approval.website ? "ti ti-world-check" : "ti ti-message-check",
420
+ action: () => submit({ approved: true, remember: "chat" }),
421
+ },
422
+ ]
423
+ : []),
396
424
  ...(props.block.approval?.allowAlways
397
425
  ? [
398
426
  {
@@ -518,11 +546,11 @@ function CapabilityToolView(props: { block: ToolBlock }) {
518
546
  function RejectedToolView(props: { block: ToolBlock }) {
519
547
  const locale = useLocale();
520
548
  const presentation = () => props.block.presentation;
521
- const title = () => presentation()?.title ?? displayToolName(props.block.name, locale());
549
+ const title = () => toolSubject(props.block, locale()) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
522
550
  return (
523
551
  <Chat.Activity
524
552
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
525
- label={aiChatMessages(locale()).toolRejected({ title: title() })}
553
+ label={toolOutcome(props.block, "rejected", locale()) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
526
554
  accent={presentation()?.appAccent}
527
555
  />
528
556
  );
@@ -694,6 +722,9 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
694
722
  <Match when={isCardToolName(props.block.name) && !props.block.isError}>
695
723
  <CloudCardBlock args={props.block.args} />
696
724
  </Match>
725
+ <Match when={isChartToolName(props.block.name) && !props.block.isError}>
726
+ <CloudChartBlock args={props.block.args} completed={status() === "completed"} />
727
+ </Match>
697
728
  <Match when={isSurveyToolName(props.block.name) && !props.block.isError}>
698
729
  <SurveyToolView turnId={props.turnId} block={props.block} active={props.active} />
699
730
  </Match>
@@ -704,6 +735,9 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
704
735
  <Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
705
736
  </Match>
706
737
  </Switch>
738
+ <Show when={websiteReceipts(props.block).length > 0}>
739
+ <WebsiteReceipts block={props.block} />
740
+ </Show>
707
741
  <Show when={hasCapabilityTable(props.block)}>
708
742
  <CapabilityTablePreview
709
743
  result={props.block.result}
@@ -791,7 +825,22 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
791
825
  const locale = useLocale();
792
826
  const t = () => aiChatMessages(locale());
793
827
  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");
828
+ /**
829
+ * A call that acts on something reads as its sentence, which already names what it acts on: what it will do while
830
+ * it runs or waits, what it did once done, and what did not happen when it was rejected.
831
+ */
832
+ const sentence = () => {
833
+ const status = props.block.status;
834
+ const outcome =
835
+ status === "completed" && !props.block.isError
836
+ ? toolOutcome(props.block, "done", locale())
837
+ : status === "rejected"
838
+ ? toolOutcome(props.block, "rejected", locale())
839
+ : null;
840
+ return outcome ?? toolSubject(props.block, locale());
841
+ };
842
+ const detail = () =>
843
+ sentence() ? undefined : [args().path, args().name, args().query, args().title].find((value) => typeof value === "string");
795
844
  const waiting = () => props.block.status === "awaiting_client" || props.block.status === "awaiting_approval";
796
845
  const state = () =>
797
846
  waiting() ? t().stepWaiting : props.block.status === "rejected" ? t().stepRejected : isFailedTool(props.block) ? t().stepFailed : "";
@@ -817,35 +866,43 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
817
866
  return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
818
867
  };
819
868
  return (
820
- <AiToolActivity
821
- blockId={props.block.id}
822
- label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
823
- description={[
824
- props.block.status === "running" ? (props.block.progress ?? "") : "",
825
- typeof detail() === "string" ? String(detail()) : "",
826
- state(),
827
- outcome(),
828
- ]
829
- .filter(Boolean)
830
- .join(" · ")}
831
- icon={icon()}
832
- busy={props.busy && props.block.status === "running"}
833
- bodyInset={false}
834
- renderBody={() => (
835
- <div class="flex min-w-0 flex-col gap-2">
836
- <div
837
- class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
838
- tabIndex={0}
839
- role="region"
840
- aria-label={t().toolInputOutput}
841
- >
842
- <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
843
- <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
869
+ <>
870
+ <AiToolActivity
871
+ blockId={props.block.id}
872
+ label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
873
+ description={[
874
+ props.block.status === "running" ? (props.block.progress ?? "") : "",
875
+ typeof detail() === "string" ? String(detail()) : "",
876
+ state(),
877
+ outcome(),
878
+ ]
879
+ .filter(Boolean)
880
+ .join(" · ")}
881
+ icon={icon()}
882
+ busy={props.busy && props.block.status === "running"}
883
+ bodyInset={false}
884
+ renderBody={() => (
885
+ <div class="flex min-w-0 flex-col gap-2">
886
+ <div
887
+ class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
888
+ tabIndex={0}
889
+ role="region"
890
+ aria-label={t().toolInputOutput}
891
+ >
892
+ <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
893
+ <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
894
+ </div>
895
+ {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
896
+ <Show when={image()}>
897
+ {(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}
898
+ </Show>
844
899
  </div>
845
- {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
846
- <Show when={image()}>{(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}</Show>
847
- </div>
848
- )}
849
- />
900
+ )}
901
+ />
902
+ {/* Each request a website approval let through is a step of its own, with its full URL. */}
903
+ <Show when={websiteReceipts(props.block).length > 0}>
904
+ <WebsiteReceipts block={props.block} />
905
+ </Show>
906
+ </>
850
907
  );
851
908
  }
@@ -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
+ }
@@ -12,7 +12,7 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
12
12
  * `window.steer(text)` appends a pending steer as the controller does,
13
13
  * `window.reconnect(on)` sets whether the stream reconnects,
14
14
  * `window.continued` lists the messages that **Continue** sent,
15
- * `window.approvals` lists the approval decisions, and
15
+ * `window.approvals` lists the approval decisions, `window.revoked` the websites a receipt revoked, and
16
16
  * `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
17
17
  */
18
18
  const [state, setState] = createStore(emptyProjection());
@@ -20,9 +20,11 @@ const [reconnecting, setReconnecting] = createSignal(false);
20
20
  const continued: string[] = [];
21
21
  const opened: string[] = [];
22
22
  const approvals: unknown[] = [];
23
+ const revoked: string[] = [];
23
24
  Object.assign(window, {
24
25
  continued,
25
26
  approvals,
27
+ revoked,
26
28
  opened,
27
29
  reconnect: (on: boolean) => setReconnecting(on),
28
30
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
@@ -42,6 +44,7 @@ render(
42
44
  actions={{
43
45
  onContinueTurn: (message) => void continued.push(message),
44
46
  onApproval: async (request, input) => void approvals.push({ callId: request.callId, ...input }),
47
+ onRevokeWebsite: async (origin) => void revoked.push(origin),
45
48
  fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
46
49
  onOpenFile: (path) => void opened.push(path),
47
50
  }}
@@ -26,7 +26,7 @@ import { aiChatMessages } from "./messages";
26
26
  /** The active-turn coordinates an approval/tool action needs to resolve on the server. */
27
27
  export type AiTurnActionRequest = { turnId: string; callId: string; name: string };
28
28
 
29
- type ApprovalHandler = (request: AiTurnActionRequest, input: { approved: boolean; remember?: "always" }) => void | Promise<void>;
29
+ type ApprovalHandler = (request: AiTurnActionRequest, input: { approved: boolean; remember?: "chat" | "always" }) => void | Promise<void>;
30
30
  type FrontendToolResultHandler = (request: AiTurnActionRequest, result: unknown) => void | Promise<void>;
31
31
  type ForkMessageHandler = (entry: AiStoredMessage, input?: AiForkMessageInput) => void | Promise<void>;
32
32
  type RetryMessageHandler = (entry: AiStoredMessage, input?: AiRetryMessageInput) => void | Promise<void>;
@@ -42,6 +42,8 @@ export type AiChatActions = {
42
42
  /** Prevents turn-continuation actions while the current turn is stopping. */
43
43
  actionDisabled?: () => boolean;
44
44
  onApproval?: ApprovalHandler;
45
+ /** Revoke the approval that lets this chat read `origin` without asking; the next request asks again. */
46
+ onRevokeWebsite?: (origin: string) => Promise<void>;
45
47
  onFrontendToolResult?: FrontendToolResultHandler;
46
48
  onForkMessage?: ForkMessageHandler;
47
49
  onRetryMessage?: RetryMessageHandler;
@@ -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",
@@ -96,9 +98,10 @@ const messages = i18n.define({
96
98
  openImage: ({ name }: { name: string }) => `Open image ${name}`,
97
99
  steered: "Conversation steered",
98
100
  groupLoad: "Loaded tools and guidance",
101
+ repeatCount: ({ count }: { count: number }) => `${count} times`,
99
102
  groupFailed: ({ count }: { count: number }) => `${count} failed`,
100
103
  groupRejected: ({ count }: { count: number }) => `${count} rejected`,
101
- receiptDone: ({ title }: { title: string }) => `${title} done`,
104
+ receiptDone: ({ title }: { title: string }) => `Done: ${title}`,
102
105
  receiptApproved: ({ title }: { title: string }) => `Approved: ${title}`,
103
106
  receiptRunning: ({ title }: { title: string }) => `Running: ${title}`,
104
107
  approvalNeeded: ({ title }: { title: string }) => `Approval needed: ${title}`,
@@ -106,6 +109,7 @@ const messages = i18n.define({
106
109
  receiptRejected: ({ title }: { title: string }) => `Rejected: ${title}`,
107
110
  receiptFailed: ({ title }: { title: string }) => `Failed: ${title}`,
108
111
  receiptNotRun: ({ title, stopped }: { title: string; stopped: boolean }) => `Not run: ${title}${stopped ? " · stopped" : ""}`,
112
+ receiptNotRunSentence: ({ sentence, stopped }: { sentence: string; stopped: boolean }) => `${sentence}${stopped ? " · stopped" : ""}`,
109
113
  workSteps: "Work steps",
110
114
  streamLoginRequired: "Your session has ended. Sign in again to continue this chat.",
111
115
  streamAccessDenied: "You no longer have access to this chat.",
@@ -117,6 +121,14 @@ const messages = i18n.define({
117
121
  approved: "Approved",
118
122
  rejected: "Rejected",
119
123
  alwaysApprove: "Always approve",
124
+ chatApprove: "Approve for this chat",
125
+ allowWebsiteForChat: "Allow this website for this chat",
126
+ websiteAllowedForChat: ({ count }: { count: number }) =>
127
+ `Allowed for this chat · ${count === 1 ? "1 request without asking" : `${count} requests without asking`}`,
128
+ revoke: "Revoke",
129
+ revokeFailed: "Could not revoke. Try again.",
130
+ revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
131
+ websiteRevoked: "Revoked. The next request asks again.",
120
132
  details: "Details",
121
133
  hideDetails: "Hide details",
122
134
  moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
@@ -290,6 +302,7 @@ const messages = i18n.define({
290
302
  tableLinks: "Links",
291
303
  tableOpen: "Öffnen",
292
304
  tableEmpty: "Keine passenden Zeilen.",
305
+ chartUnavailable: "Dieses Diagramm kann nicht angezeigt werden.",
293
306
  tableCapped:
294
307
  "Die ersten 100 zurückgegebenen Zeilen werden angezeigt. Das vollständige Werkzeugergebnis bleibt in den Details verfügbar.",
295
308
  tableMore: "Weitere Zeilen sind verfügbar. Frage nach der nächsten Seite oder öffne das Ergebnis in der Anwendung.",
@@ -372,6 +385,7 @@ const messages = i18n.define({
372
385
  stepDeliveringFile: "Stellt eine Datei bereit",
373
386
  stepPreparingView: "Bereitet eine Ansicht vor",
374
387
  stepPreparingCard: "Bereitet eine Karte vor",
388
+ stepPreparingChart: "Bereitet ein Diagramm vor",
375
389
  stepCompacting: "Fasst das Gespräch zusammen",
376
390
  stepTool: "Nutzt ein Werkzeug",
377
391
  stepFailed: "fehlgeschlagen",
@@ -384,9 +398,10 @@ const messages = i18n.define({
384
398
  openImage: ({ name }) => `Bild ${name} öffnen`,
385
399
  steered: "Richtung geändert",
386
400
  groupLoad: "Werkzeuge und Wissen geladen",
401
+ repeatCount: ({ count }) => `${count}-mal`,
387
402
  groupFailed: ({ count }) => `${count} fehlgeschlagen`,
388
403
  groupRejected: ({ count }) => `${count} abgelehnt`,
389
- receiptDone: ({ title }) => `${title} erledigt`,
404
+ receiptDone: ({ title }) => `Erledigt: ${title}`,
390
405
  receiptApproved: ({ title }) => `Freigegeben: ${title}`,
391
406
  receiptRunning: ({ title }) => `Wird ausgeführt: ${title}`,
392
407
  approvalNeeded: ({ title }) => `Freigabe nötig: ${title}`,
@@ -394,6 +409,7 @@ const messages = i18n.define({
394
409
  receiptRejected: ({ title }) => `Abgelehnt: ${title}`,
395
410
  receiptFailed: ({ title }) => `Fehlgeschlagen: ${title}`,
396
411
  receiptNotRun: ({ title, stopped }) => `Nicht ausgeführt: ${title}${stopped ? " · gestoppt" : ""}`,
412
+ receiptNotRunSentence: ({ sentence, stopped }) => `${sentence}${stopped ? " · gestoppt" : ""}`,
397
413
  workSteps: "Arbeitsschritte",
398
414
  streamLoginRequired: "Deine Sitzung ist abgelaufen. Melde dich erneut an, um diesen Chat fortzusetzen.",
399
415
  streamAccessDenied: "Du hast keinen Zugriff mehr auf diesen Chat.",
@@ -405,6 +421,14 @@ const messages = i18n.define({
405
421
  approved: "Freigegeben",
406
422
  rejected: "Abgelehnt",
407
423
  alwaysApprove: "Immer freigeben",
424
+ chatApprove: "Für diesen Chat freigeben",
425
+ allowWebsiteForChat: "Diese Website für diesen Chat erlauben",
426
+ websiteAllowedForChat: ({ count }: { count: number }) =>
427
+ `Für diesen Chat erlaubt · ${count === 1 ? "1 Abruf ohne Rückfrage" : `${count} Abrufe ohne Rückfrage`}`,
428
+ revoke: "Entziehen",
429
+ revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
430
+ revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
431
+ websiteRevoked: "Entzogen. Der nächste Abruf fragt wieder.",
408
432
  details: "Details",
409
433
  hideDetails: "Details ausblenden",
410
434
  moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
@@ -1,18 +1,20 @@
1
1
  import { aiSkillFilePathFromMount } from "../file-mount";
2
2
  import type { AiTurnBlock } from "../protocol";
3
+ import { isRecord } from "./message-utils";
3
4
  import { aiChatMessages } from "./messages";
4
5
 
5
6
  type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
6
7
  type Text = Extract<AiTurnBlock, { kind: "text" }>;
7
8
  export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
8
9
  /**
9
- * One row of the expanded work, all at one indent: an intermediate text, a step, or a group that folds housekeeping
10
- * steps and opens in place.
10
+ * One row of the expanded work: an intermediate text, a step, or a group that folds housekeeping or repeated calls
11
+ * on one target and opens in place.
11
12
  */
12
13
  export type AiWorkItem =
13
14
  | { kind: "text"; block: Text }
14
15
  | { kind: "step"; entry: AiWorkEntry }
15
- | { kind: "housekeeping"; id: string; entries: AiWorkEntry[] };
16
+ | { kind: "housekeeping"; id: string; entries: AiWorkEntry[] }
17
+ | { kind: "repeat"; id: string; target: string; entries: AiWorkEntry[] };
16
18
 
17
19
  /** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
18
20
  export function isFailedTool(tool: Tool): boolean {
@@ -50,33 +52,85 @@ export const isHousekeepingTool = (tool: Tool): boolean => {
50
52
 
51
53
  const isStep = (entry: AiWorkEntry) => entry.kind === "tool" || entry.kind === "compaction";
52
54
 
55
+ /** The file, address, or query a folded row shows. A call without one never folds with others. */
56
+ const callTarget = (tool: Tool): string | undefined => {
57
+ const args = isRecord(tool.args) ? tool.args : {};
58
+ return [args.path, args.url, args.query].find((value): value is string => typeof value === "string" && value.trim() !== "")?.trim();
59
+ };
60
+
61
+ /** Arguments that page through one call's result, such as read_file's byte slices or an app's cursor and page size. */
62
+ const PAGING_ARGUMENTS = new Set(["offset", "length", "limit", "cursor", "after", "page", "pageSize"]);
63
+
64
+ type Fold = { kind: "housekeeping" } | { kind: "repeat"; name: string; target: string; identity: string };
65
+
66
+ /**
67
+ * What a step folds with: housekeeping with housekeeping, a running or finished call with the same call apart from
68
+ * paging, such as reading one file in parts. Repeated calls need a target; failed, rejected, and waiting calls never repeat.
69
+ */
70
+ const foldOf = (tool: Tool): Fold | null => {
71
+ if (isHousekeepingTool(tool)) return { kind: "housekeeping" };
72
+ const target = callTarget(tool);
73
+ if (!target || isFailedTool(tool) || (tool.status !== "completed" && tool.status !== "running")) return null;
74
+ const args = isRecord(tool.args) ? tool.args : {};
75
+ const identity = JSON.stringify(
76
+ Object.fromEntries(Object.entries(args).filter(([key]) => !PAGING_ARGUMENTS.has(key))),
77
+ (_key: string, value: unknown) =>
78
+ typeof value === "string"
79
+ ? value.trim()
80
+ : isRecord(value)
81
+ ? Object.fromEntries(
82
+ Object.keys(value)
83
+ .sort()
84
+ .map((key) => [key, value[key]]),
85
+ )
86
+ : value,
87
+ );
88
+ return { kind: "repeat", name: tool.name, target, identity };
89
+ };
90
+
91
+ const sameFold = (a: Fold, b: Fold): boolean =>
92
+ a.kind === "housekeeping" ? b.kind === "housekeeping" : b.kind === "repeat" && a.name === b.name && a.identity === b.identity;
93
+
53
94
  /**
54
- * The rows of the expanded work, in the original order. A run of at least two housekeeping steps, with the reasoning
55
- * between them, folds into one group, unless it would hold all but one of the work's steps: then the steps show
56
- * directly, so the work line never opens to a summary of itself. Entries listed in `shown` already show as rows of
57
- * their own and never fold into a group, so nothing a reader sees folds away under them. A group is keyed by its first
58
- * tool, whose id is the same live and in history.
95
+ * The rows of the expanded work, in the original order. A run of at least two steps that fold together, with the
96
+ * reasoning between them, becomes one row: housekeeping, or consecutive instances of the same call apart from paging,
97
+ * such as reading one file in parts. Anything else between them, such as an intermediate text, ends the run. A run that
98
+ * would hold all but one of the work's steps shows its steps directly, so the work line never opens to a summary of
99
+ * itself. Entries listed in `shown` already show as rows of their own and never fold, so nothing a reader sees folds
100
+ * away under them.
101
+ * A folded row is keyed by its first tool, whose id is the same live and in history.
59
102
  */
60
103
  export function groupWorkBlocks(blocks: readonly AiTurnBlock[], shown: ReadonlySet<string> = new Set()): AiWorkItem[] {
61
104
  const entries = blocks.filter((block): block is AiWorkEntry | Text => block.kind !== "steer_message");
62
105
  const steps = entries.filter((entry) => entry.kind !== "text" && isStep(entry)).length;
63
106
  const items: AiWorkItem[] = [];
107
+ let fold: Fold | null = null;
64
108
  let run: AiWorkEntry[] = [];
65
109
  let trailing: AiWorkEntry[] = [];
66
110
  const flush = () => {
67
- if (run.length > 0) {
111
+ if (fold && run.length > 0) {
68
112
  const runSteps = run.filter(isStep).length;
69
- if (runSteps >= 2 && steps - runSteps >= 2) items.push({ kind: "housekeeping", id: run[0]!.id, entries: run });
113
+ const id = run[0]!.id;
114
+ if (runSteps >= 2 && steps - runSteps >= 2)
115
+ items.push(
116
+ fold.kind === "housekeeping"
117
+ ? { kind: "housekeeping", id, entries: run }
118
+ : { kind: "repeat", id, target: fold.target, entries: run },
119
+ );
70
120
  else for (const entry of run) items.push({ kind: "step", entry });
71
121
  }
72
- // Reasoning after the last housekeeping step belongs to what comes next.
122
+ // Reasoning after the last folded step belongs to what comes next.
73
123
  for (const entry of trailing) items.push({ kind: "step", entry });
124
+ fold = null;
74
125
  run = [];
75
126
  trailing = [];
76
127
  };
77
128
  for (const entry of entries) {
78
129
  const foldable = !shown.has(entry.id);
79
- if (foldable && entry.kind === "tool" && isHousekeepingTool(entry)) {
130
+ const next = foldable && entry.kind === "tool" ? foldOf(entry) : null;
131
+ if (next && entry.kind === "tool") {
132
+ if (fold && !sameFold(fold, next)) flush();
133
+ fold = next;
80
134
  run.push(...trailing, entry);
81
135
  trailing = [];
82
136
  } else if (foldable && entry.kind === "thinking" && run.length > 0) trailing.push(entry);
@@ -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",
@@ -71,12 +72,14 @@ const toolNames = i18n.define({
71
72
  code_restore: "Restore app version",
72
73
  code_update: "Update app details",
73
74
  code_create: "Create app",
74
- code_write: "Save app files",
75
+ code_write: "Save app file",
76
+ code_import: "Import app files",
75
77
  code_remove: "Remove app file",
76
78
  },
77
79
  de: {
78
80
  calculate: "Rechnen",
79
81
  card: "Karte",
82
+ chart: "Diagramm",
80
83
  survey: "Umfrage",
81
84
  text_editor: "Texteditor",
82
85
  local_bash: "Lokale Bash",
@@ -138,7 +141,8 @@ const toolNames = i18n.define({
138
141
  code_restore: "App-Version wiederherstellen",
139
142
  code_update: "App-Details ändern",
140
143
  code_create: "App erstellen",
141
- code_write: "App-Dateien speichern",
144
+ code_write: "App-Datei speichern",
145
+ code_import: "App-Dateien importieren",
142
146
  code_remove: "App-Datei entfernen",
143
147
  },
144
148
  },