@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
@@ -1,7 +1,7 @@
1
1
  import { type AiTurnBlock, isRenderableTurnBlock } from "../protocol";
2
2
  import type { AiStoredMessage } from "../types";
3
3
  import { hasCapabilityTable } from "./capability-result";
4
- import { isCardToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
4
+ import { isCardToolName, isChartToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
5
5
  import { isFailedTool } from "./tool-groups";
6
6
 
7
7
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -35,6 +35,11 @@ export type AiTurnLayout = {
35
35
  results: AiTurnResult[];
36
36
  /** Place 3: the newest text. Live it is a status; finished it is the final message. */
37
37
  text: TextBlock[];
38
+ /**
39
+ * Place 3 is only a status: the turn runs and a step started after its text. A trailing text may still be the answer
40
+ * and keeps answer typography; it turns quiet when the next step starts, together with the work line's new label.
41
+ */
42
+ textIsStatus: boolean;
38
43
  /** Place 4: effects in Cloud and decisions, open as a card or finished as a receipt. */
39
44
  actions: AiTurnAction[];
40
45
  /** The newest block, which names the current step of a live turn. */
@@ -69,7 +74,7 @@ const isResult = (block: ToolBlock, phase: AiTurnPhase, codePresentations: boole
69
74
  // A running delivery reserves its place once its arguments arrived: a new version of an earlier result must find the
70
75
  // earlier place before it takes one of its own.
71
76
  const delivered = block.status === "completed" || (live(phase) && block.status === "running" && block.args !== undefined);
72
- if (block.name === "present" || isCardToolName(block.name)) return delivered;
77
+ if (block.name === "present" || isCardToolName(block.name) || isChartToolName(block.name)) return delivered;
73
78
  if (block.name === "code_present") return codePresentations && delivered;
74
79
  return hasCapabilityTable(block);
75
80
  };
@@ -124,6 +129,8 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
124
129
  }
125
130
  }
126
131
  const visible = new Set(text);
132
+ const lastStep = blocks.findLastIndex((block) => block.kind === "tool" || block.kind === "compaction");
133
+ const shownAt = text.length > 0 ? blocks.indexOf(text.at(-1)!) : -1;
127
134
  const work = blocks.filter((block) => !visible.has(block as TextBlock));
128
135
 
129
136
  const waits = tools.filter(waitsForUser);
@@ -135,6 +142,7 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
135
142
  showWork: steps > 0 || ((options.phase === "stopped" || options.phase === "failed") && work.length > 0),
136
143
  results,
137
144
  text,
145
+ textIsStatus: live(options.phase) && shownAt >= 0 && shownAt < lastStep,
138
146
  actions,
139
147
  current: blocks.at(-1) ?? null,
140
148
  waitingFor,
@@ -17,6 +17,7 @@ import {
17
17
  import { markdown } from "../../shared";
18
18
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
19
19
  import type { AiTurnError } from "../types";
20
+ import { toolOutcome, toolSubject } from "./action-sentences";
20
21
  import {
21
22
  type AiApprovalDecision,
22
23
  ApprovalBlockView,
@@ -26,9 +27,18 @@ import {
26
27
  TextEditorToolView,
27
28
  } from "./blocks";
28
29
  import { CapabilityTablePreview } from "./capability-table";
30
+ import { CloudChartBlock } from "./chart-block";
29
31
  import { PresentToolBlock } from "./file-tools";
30
32
  import { useAiChatActions } from "./message-actions";
31
- import { aiToolIcon, capabilityErrorDescription, displayToolName, isCardToolName, isRecord, isSurveyToolName } from "./message-utils";
33
+ import {
34
+ aiToolIcon,
35
+ capabilityErrorDescription,
36
+ displayToolName,
37
+ isCardToolName,
38
+ isChartToolName,
39
+ isRecord,
40
+ isSurveyToolName,
41
+ } from "./message-utils";
32
42
  import { aiChatMessages } from "./messages";
33
43
  import { AssistantMarkdownBlock } from "./primitives";
34
44
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
@@ -133,7 +143,8 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
133
143
  if (name === "present") return withTarget(t.stepDeliveringFile);
134
144
  if (name === "code_present") return withTarget(t.stepPreparingView);
135
145
  if (isCardToolName(name)) return t.stepPreparingCard;
136
- if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
146
+ if (isChartToolName(name)) return t.stepPreparingChart;
147
+ if (["code_write", "code_import", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
137
148
  return withTarget(t.stepWritingCode);
138
149
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
139
150
  if (name === "code_check") return t.stepCheckingApp;
@@ -225,6 +236,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
225
236
  tone={working() ? "ai" : "neutral"}
226
237
  busy={working()}
227
238
  trailing={trailing()}
239
+ bodyInset={false}
228
240
  renderBody={() => <AiWorkSteps blocks={layout().work} active={working()} />}
229
241
  />
230
242
  );
@@ -264,7 +276,27 @@ function WorkEntryRow(props: { entry: AiWorkEntry; busy: boolean }) {
264
276
  );
265
277
  }
266
278
 
267
- /** Housekeeping folded into one row at step level. It opens in place: its steps follow at the same indent. */
279
+ /** The steps of an opened group, keyed by block id, so a row keeps its element and focus when its block is replaced. */
280
+ function WorkEntries(props: { entries: AiWorkEntry[]; active: boolean }) {
281
+ const entries = createMemo(() => new Map(props.entries.map((entry) => [entry.id, entry])));
282
+ const ids = createMemo(() => [...entries().keys()]);
283
+ return (
284
+ <div class="ai-turn-steps">
285
+ <For each={ids()}>
286
+ {(id, index) => (
287
+ <Show when={entries().get(id)}>
288
+ {(entry) => <WorkEntryRow entry={entry()} busy={props.active && index() === ids().length - 1} />}
289
+ </Show>
290
+ )}
291
+ </For>
292
+ </div>
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Housekeeping folded into one row at step level, with its count at the end like the work line's. It opens in place:
298
+ * its steps follow at the same indent.
299
+ */
268
300
  function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
269
301
  const locale = useLocale();
270
302
  const last = () => props.entries.at(-1)!;
@@ -272,17 +304,33 @@ function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active:
272
304
  <AiToolActivity
273
305
  blockId={`group:${props.id}`}
274
306
  label={aiChatMessages(locale()).groupLoad}
275
- description={countWorkSteps(props.entries, locale())}
307
+ trailing={<span class="ai-turn-work__meta">{countWorkSteps(props.entries, locale())}</span>}
276
308
  icon="ti ti-stack-2"
277
309
  bodyInset={false}
278
310
  busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
279
- renderBody={() => (
280
- <div class="ai-turn-steps">
281
- <For each={props.entries}>
282
- {(entry, index) => <WorkEntryRow entry={entry} busy={props.active && index() === props.entries.length - 1} />}
283
- </For>
284
- </div>
285
- )}
311
+ renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
312
+ />
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Consecutive instances of one call apart from paging, such as reading one file in parts, folded into one row that
318
+ * opens in place. Its count sits at the end like the work line's, so a narrow screen cuts the target short, never the count.
319
+ */
320
+ function RepeatedSteps(props: { id: string; target: string; entries: AiWorkEntry[]; active: boolean }) {
321
+ const locale = useLocale();
322
+ const tools = () => props.entries.filter((entry): entry is ToolBlock => entry.kind === "tool");
323
+ const first = () => tools()[0]!;
324
+ return (
325
+ <AiToolActivity
326
+ blockId={`repeat:${props.id}`}
327
+ label={first().presentation?.title ?? displayToolName(first().name, locale())}
328
+ description={props.target}
329
+ trailing={<span class="ai-turn-work__meta">{aiChatMessages(locale()).repeatCount({ count: tools().length })}</span>}
330
+ icon={aiToolIcon(first().name, first().presentation?.appIcon)}
331
+ bodyInset={false}
332
+ busy={props.active && tools().at(-1)?.status === "running"}
333
+ renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
286
334
  />
287
335
  );
288
336
  }
@@ -300,13 +348,19 @@ function WorkText(props: { text: string }) {
300
348
  }
301
349
 
302
350
  const workItemKey = (item: AiWorkItem): string =>
303
- item.kind === "text" ? `text:${item.block.id}` : item.kind === "step" ? `step:${item.entry.id}` : `group:${item.id}`;
351
+ item.kind === "text"
352
+ ? `text:${item.block.id}`
353
+ : item.kind === "step"
354
+ ? `step:${item.entry.id}`
355
+ : item.kind === "repeat"
356
+ ? `repeat:${item.id}`
357
+ : `group:${item.id}`;
304
358
 
305
359
  /**
306
- * Expanded work, one indent below the work line and one size: intermediate texts as quiet paragraphs, the steps between
307
- * them directly, and housekeeping folded at step level. A row shown while this list is open never folds away under the
308
- * reader, also when reasoning or arguments that would fold it arrive later; the next time it opens, it folds like in
309
- * history.
360
+ * Expanded work, at the work line's indent and one size: intermediate texts as quiet paragraphs, the steps between
361
+ * them directly, and housekeeping or repeated steps folded at step level. A row shown while this list is open never
362
+ * folds away under the reader, also when reasoning or arguments that would fold it arrive later; the next time it
363
+ * opens, it folds like in history.
310
364
  */
311
365
  function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
312
366
  const shown = new Set<string>();
@@ -333,12 +387,19 @@ function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
333
387
  const value = item();
334
388
  return value?.kind === "housekeeping" ? value : undefined;
335
389
  };
390
+ const repeat = () => {
391
+ const value = item();
392
+ return value?.kind === "repeat" ? value : undefined;
393
+ };
336
394
  const active = () => props.active && index() === keys().length - 1;
337
395
  return (
338
396
  <Switch>
339
397
  <Match when={text()}>{(value) => <WorkText text={value()} />}</Match>
340
398
  <Match when={step()}>{(entry) => <WorkEntryRow entry={entry()} busy={active()} />}</Match>
341
399
  <Match when={group()}>{(value) => <HousekeepingGroup id={value().id} entries={value().entries} active={active()} />}</Match>
400
+ <Match when={repeat()}>
401
+ {(value) => <RepeatedSteps id={value().id} target={value().target} entries={value().entries} active={active()} />}
402
+ </Match>
342
403
  </Switch>
343
404
  );
344
405
  }}
@@ -436,6 +497,9 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
436
497
  <Match when={isCardToolName(current().name)}>
437
498
  <CloudCardBlock args={current().args} />
438
499
  </Match>
500
+ <Match when={isChartToolName(current().name)}>
501
+ <CloudChartBlock args={current().args} completed={current().status === "completed"} />
502
+ </Match>
439
503
  <Match when={true}>
440
504
  <Show when={!props.receipt()}>
441
505
  <CapabilityResultRow block={current()} />
@@ -455,33 +519,37 @@ type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
455
519
 
456
520
  /**
457
521
  * One line for an action in place 4, the same element while it runs and once it is done, rejected, failed, or not run.
458
- * It names the action in the reader's language with what it acted on, such as "Rejected: Run code · report.ts", or the
459
- * application's own summary of what it did.
522
+ * It says what happened in the owning app's words: its sentence for the outcome, such as "Moved report.pdf to the
523
+ * trash", else, once done, the application's own summary of the call. Without either it puts the state in the reader's
524
+ * language before what the call does, such as "Rejected: Write report.md", or before its title and target.
460
525
  */
461
526
  function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
462
527
  const locale = useLocale();
463
528
  const t = () => aiChatMessages(locale());
464
529
  const block = () => props.block;
465
530
  const subject = () => {
531
+ const sentence = toolSubject(block(), locale());
532
+ if (sentence) return sentence;
466
533
  const title = block().presentation?.title ?? displayToolName(block().name, locale());
467
534
  const target = toolTarget(block());
468
535
  return target ? `${title} · ${target}` : title;
469
536
  };
537
+ const capabilityAction = () => block().presentation?.kind === "capability";
470
538
  const links = () => (props.state === "done" ? resultLinks(block()) : []);
471
539
  const label = () => {
472
540
  const state = props.state;
473
541
  if (state === "running") return t().receiptRunning({ title: subject() });
474
542
  if (state === "done") {
475
- const summary = resultSummary(block());
476
- if (summary) return summary;
477
543
  // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
478
- return block().presentation?.kind === "capability"
479
- ? t().receiptDone({ title: subject() })
480
- : t().receiptApproved({ title: subject() });
544
+ if (!capabilityAction()) return t().receiptApproved({ title: subject() });
545
+ return toolOutcome(block(), "done", locale()) || resultSummary(block()) || t().receiptDone({ title: subject() });
481
546
  }
482
- if (state === "rejected") return t().receiptRejected({ title: subject() });
547
+ if (state === "rejected") return toolOutcome(block(), "rejected", locale()) ?? t().receiptRejected({ title: subject() });
483
548
  if (state === "failed") return t().receiptFailed({ title: subject() });
484
- return t().receiptNotRun({ title: subject(), stopped: props.stopped });
549
+ const notRun = toolOutcome(block(), "notRun", locale());
550
+ return notRun
551
+ ? t().receiptNotRunSentence({ sentence: notRun, stopped: props.stopped })
552
+ : t().receiptNotRun({ title: subject(), stopped: props.stopped });
485
553
  };
486
554
  const declined = () => props.state === "rejected" || props.state === "not_run";
487
555
  return (
@@ -529,7 +597,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
529
597
  if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
530
598
  announce(
531
599
  aiChatMessages(locale()).approvalNeeded({
532
- title: current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
600
+ title: toolSubject(current.block, locale()) ?? current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
533
601
  }),
534
602
  );
535
603
  }),
@@ -623,14 +691,18 @@ function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean })
623
691
  }
624
692
 
625
693
  /**
626
- * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
627
- * is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
694
+ * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. Once a step
695
+ * started after it, it reads quietly like the texts in the expanded work; a trailing text may still be the answer and
696
+ * keeps answer typography. A status is not read token by token: screen readers hear each status once it is complete,
697
+ * and that the answer is ready.
628
698
  */
629
699
  function AiTurnText(props: {
630
700
  blocks: Accessor<Extract<AiTurnBlock, { kind: "text" }>[]>;
631
701
  live: Accessor<boolean>;
632
702
  /** The turn has work steps, so its live text is a status rather than the answer itself. */
633
703
  folding: Accessor<boolean>;
704
+ /** A step started after the text, so it can no longer be the answer: it reads quietly. */
705
+ quiet: Accessor<boolean>;
634
706
  /** An earlier segment of a split turn: it stops being live while the turn goes on. */
635
707
  earlier: Accessor<boolean>;
636
708
  }) {
@@ -644,6 +716,7 @@ function AiTurnText(props: {
644
716
  );
645
717
  const html = createMemo(() => markdown.renderSync(source(), { locale: locale() }));
646
718
  const shownId = createMemo(() => props.blocks()[0]?.id);
719
+ const quiet = createMemo(() => props.quiet());
647
720
  const [minHeight, setMinHeight] = createSignal<number | null>(null);
648
721
  let element!: HTMLDivElement;
649
722
  let lastHeight = 0;
@@ -655,14 +728,21 @@ function AiTurnText(props: {
655
728
  observer.observe(element);
656
729
  onCleanup(() => observer.disconnect());
657
730
  });
731
+ /** The quiet text's height in answer typography, measured in place before the browser paints. */
732
+ const answerHeight = () => {
733
+ element.classList.remove("ai-turn__text--status");
734
+ const height = element.offsetHeight;
735
+ element.classList.add("ai-turn__text--status");
736
+ return height;
737
+ };
738
+ // A new status keeps the height of the one before it. A quiet status keeps the height it has as the answer, also when
739
+ // it first shows quietly after a reload, so nothing below moves when it is replaced or becomes the answer again.
658
740
  createEffect(
659
- on(
660
- shownId,
661
- () => {
662
- if (props.live() && lastHeight > 0) setMinHeight((current) => Math.max(current ?? 0, lastHeight));
663
- },
664
- { defer: true },
665
- ),
741
+ on([shownId, quiet], ([id, isQuiet], previous) => {
742
+ if (!props.live()) return;
743
+ const height = Math.max(previous && previous[0] !== id ? lastHeight : 0, isQuiet ? answerHeight() : 0);
744
+ if (height > 0) setMinHeight((current) => Math.max(current ?? 0, height));
745
+ }),
666
746
  );
667
747
  createEffect(() => {
668
748
  if (!props.live()) setMinHeight(null);
@@ -683,6 +763,7 @@ function AiTurnText(props: {
683
763
  <div
684
764
  ref={element}
685
765
  class="ai-turn__text"
766
+ classList={{ "ai-turn__text--status": quiet() }}
686
767
  aria-live={status() ? "off" : undefined}
687
768
  style={minHeight() ? { "min-height": `${minHeight()}px` } : undefined}
688
769
  >
@@ -739,7 +820,13 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
739
820
  </Show>
740
821
  <For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
741
822
  <Show when={layout().text.length > 0}>
742
- <AiTurnText blocks={() => layout().text} live={live} folding={() => layout().steps > 0} earlier={() => props.segment().earlier} />
823
+ <AiTurnText
824
+ blocks={() => layout().text}
825
+ live={live}
826
+ folding={() => layout().steps > 0}
827
+ quiet={() => layout().textIsStatus}
828
+ earlier={() => props.segment().earlier}
829
+ />
743
830
  </Show>
744
831
  <For each={actionIds()}>
745
832
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
@@ -1,8 +1,10 @@
1
- import { Chat, ScrollArea } from "@k2b/ui";
1
+ import { Button, Chat, ScrollArea, useLocale } from "@k2b/ui";
2
2
  import { createSignal, For, Show } from "solid-js";
3
3
  import { formatAiFileSize } from "../attachments";
4
4
  import type { AiTurnBlock } from "../protocol";
5
+ import { useAiChatActions } from "./message-actions";
5
6
  import { isRecord } from "./message-utils";
7
+ import { aiChatMessages } from "./messages";
6
8
  import { AiToolActivity } from "./tool-disclosure";
7
9
 
8
10
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -200,3 +202,95 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
200
202
  </Show>
201
203
  );
202
204
  }
205
+
206
+ type WebsiteReceipt = { method: string; url: string };
207
+
208
+ /**
209
+ * Requests a website approval for this chat let through without asking, from a code run or a page read. The call
210
+ * reports them as they go out, so they are there whether it succeeded, failed, or its turn stopped.
211
+ */
212
+ export const websiteReceipts = (block: ToolBlock): WebsiteReceipt[] => block.receipts ?? [];
213
+
214
+ const originOf = (url: string): string => {
215
+ try {
216
+ return new URL(url).origin;
217
+ } catch {
218
+ return url;
219
+ }
220
+ };
221
+
222
+ /**
223
+ * The receipt of every request a website approval let through: one row per website with each full URL, query
224
+ * included, and a revoke action that makes the next request ask again.
225
+ */
226
+ export function WebsiteReceipts(props: { block: ToolBlock }) {
227
+ const actions = useAiChatActions();
228
+ const locale = useLocale();
229
+ const t = () => aiChatMessages(locale());
230
+ const groups = () => {
231
+ const byOrigin = new Map<string, WebsiteReceipt[]>();
232
+ for (const receipt of websiteReceipts(props.block)) {
233
+ const origin = originOf(receipt.url);
234
+ byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), receipt]);
235
+ }
236
+ return [...byOrigin].map(([origin, receipts]) => ({ origin, receipts }));
237
+ };
238
+ return (
239
+ <For each={groups()}>
240
+ {(group) => {
241
+ const [state, setState] = createSignal<"idle" | "busy" | "revoked" | "failed">("idle");
242
+ const host = () => domainOf(group.origin);
243
+ const revoke = async () => {
244
+ if (!actions.onRevokeWebsite || state() === "busy") return;
245
+ setState("busy");
246
+ try {
247
+ await actions.onRevokeWebsite(group.origin);
248
+ setState("revoked");
249
+ } catch {
250
+ setState("failed");
251
+ }
252
+ };
253
+ return (
254
+ <Chat.Activity
255
+ icon="ti ti-world-check"
256
+ label={host()}
257
+ description={
258
+ state() === "revoked"
259
+ ? t().websiteRevoked
260
+ : state() === "failed"
261
+ ? t().revokeFailed
262
+ : t().websiteAllowedForChat({ count: group.receipts.length })
263
+ }
264
+ defaultOpen
265
+ bodyInset={false}
266
+ trailing={
267
+ // The button keeps its place and label once revoked, so nothing moves; the description says it is revoked.
268
+ <Show when={actions.onRevokeWebsite}>
269
+ <Button
270
+ size="xs"
271
+ variant="ghost"
272
+ loading={state() === "busy"}
273
+ disabled={state() === "revoked"}
274
+ aria-label={t().revokeWebsiteFor({ host: host() })}
275
+ onClick={() => void revoke()}
276
+ >
277
+ {t().revoke}
278
+ </Button>
279
+ </Show>
280
+ }
281
+ >
282
+ <ul class="flex w-full min-w-0 flex-col gap-0.5 rounded-md bg-zinc-100/70 px-2 py-1.5 text-xs [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
283
+ <For each={group.receipts}>
284
+ {(receipt) => (
285
+ <li class="min-w-0 break-all font-mono text-[11px] text-secondary">
286
+ <span class="font-semibold text-primary">{receipt.method}</span> {receipt.url}
287
+ </li>
288
+ )}
289
+ </For>
290
+ </ul>
291
+ </Chat.Activity>
292
+ );
293
+ }}
294
+ </For>
295
+ );
296
+ }
@@ -1284,7 +1284,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1284
1284
  conversationId: string,
1285
1285
  turnId: string,
1286
1286
  callId: string,
1287
- action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
1287
+ action: { type: "approval_response"; approved: boolean; remember?: "chat" | "always" } | { type: "tool_result"; result: unknown },
1288
1288
  ) => {
1289
1289
  if (isActiveConversation(conversationId) && runStatus() === "stopping") return false;
1290
1290
  try {
@@ -1325,7 +1325,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1325
1325
  const submitTurnAction = (
1326
1326
  turnId: string,
1327
1327
  callId: string,
1328
- action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
1328
+ action: { type: "approval_response"; approved: boolean; remember?: "chat" | "always" } | { type: "tool_result"; result: unknown },
1329
1329
  ) => {
1330
1330
  const conversationId = activeConversationId();
1331
1331
  if (!conversationId) return Promise.resolve(false);
@@ -1333,7 +1333,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1333
1333
  return submitTurnActionForConversation(conversationId, turnId, callId, action);
1334
1334
  };
1335
1335
 
1336
- const respondToApproval = async (request: { turnId: string; callId: string }, input: { approved: boolean; remember?: "always" }) => {
1336
+ const respondToApproval = async (
1337
+ request: { turnId: string; callId: string },
1338
+ input: { approved: boolean; remember?: "chat" | "always" },
1339
+ ) => {
1337
1340
  const conversationId = activeConversationId();
1338
1341
  if (!conversationId) return false;
1339
1342
  const submitted = await submitTurnAction(request.turnId, request.callId, {