@k2b/cloud 0.27.0 → 0.29.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 (155) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -1,7 +1,7 @@
1
1
  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
- import { createSignal, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
4
+ import { createSignal, createUniqueId, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
5
5
  import type { CapabilityActionReview } from "../../contracts/capabilities";
6
6
  import { markdown } from "../../shared";
7
7
  import type { AiTurnBlock } from "../protocol";
@@ -13,6 +13,7 @@ import { useAiChatActions } from "./message-actions";
13
13
  import {
14
14
  aiToolIcon,
15
15
  capabilityErrorDescription,
16
+ codeCheckOutcome,
16
17
  displayToolName,
17
18
  fetchFileErrorPresentation,
18
19
  formatToolDetailText,
@@ -22,6 +23,7 @@ import {
22
23
  isTextEditorToolName,
23
24
  jsonPreview,
24
25
  memoryToolPresentation,
26
+ viewedChatImage,
25
27
  } from "./message-utils";
26
28
  import { aiChatMessages } from "./messages";
27
29
  import { AssistantMarkdownBlock } from "./primitives";
@@ -200,7 +202,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
200
202
  const submit = (input: { approved: boolean; remember?: "always" }) => {
201
203
  if (!actionDisabled() && !approval.loading()) void approval.mutate(input);
202
204
  };
203
- const title = () => props.block.presentation?.title ?? displayToolName(props.block.name);
205
+ const title = () => props.block.presentation?.title ?? displayToolName(props.block.name, locale());
204
206
  const ownerName = () => props.block.presentation?.appName ?? t().assistant;
205
207
  const description = () => {
206
208
  const reviewMessage = props.block.approval?.review?.message.trim();
@@ -283,7 +285,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
283
285
  <div class="mt-4 flex flex-col gap-4 text-xs leading-5 text-secondary">
284
286
  <p class="whitespace-pre-wrap">{description()}</p>
285
287
  <Show when={inlineReviewDetails().length > 0}>
286
- <dl class="grid gap-x-5 gap-y-1.5 border-t border-[var(--k2b-border)] pt-3 sm:grid-cols-[max-content_minmax(0,1fr)]">
288
+ <dl class="grid gap-x-5 gap-y-1.5 sm:grid-cols-[max-content_minmax(0,1fr)]">
287
289
  <For each={inlineReviewDetails()}>
288
290
  {(detail) => (
289
291
  <>
@@ -313,7 +315,12 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
313
315
  </pre>
314
316
  }
315
317
  >
316
- <div class="max-h-72 overflow-auto text-xs text-secondary" role="region" tabIndex={0} aria-label={detail.label}>
318
+ <div
319
+ class="-mx-2 max-h-72 overflow-auto px-2 text-xs text-secondary"
320
+ role="region"
321
+ tabIndex={0}
322
+ aria-label={detail.label}
323
+ >
317
324
  <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
318
325
  </div>
319
326
  </Show>
@@ -323,10 +330,12 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
323
330
  </div>
324
331
  </Show>
325
332
  </div>
326
- <footer
327
- class="flex min-h-12 flex-wrap items-center gap-2 border-t border-[var(--k2b-border)] bg-[var(--k2b-surface-subtle)] px-4 py-2.5"
328
- data-ai-approval-footer
329
- >
333
+ {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
334
+ <footer class="flex min-h-12 flex-wrap items-center gap-2 px-4 pb-3" data-ai-approval-footer>
335
+ {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
336
+ <span class="k2b-sr-only" role="status" aria-live="polite">
337
+ {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
338
+ </span>
330
339
  <Show when={reviewLinks().length > 0}>
331
340
  <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
332
341
  <For each={reviewLinks()}>
@@ -339,7 +348,9 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
339
348
  </nav>
340
349
  </Show>
341
350
  <Show when={approval.error()}>
342
- <p class="text-xs text-red-700 dark:text-red-300">{t().approvalFailed}</p>
351
+ <p class="text-xs text-red-700 dark:text-red-300" role="alert">
352
+ {t().approvalFailed}
353
+ </p>
343
354
  </Show>
344
355
  <div class="ml-auto shrink-0">
345
356
  <Show
@@ -526,7 +537,7 @@ function CapabilityToolView(props: { block: ToolBlock }) {
526
537
  function RejectedToolView(props: { block: ToolBlock }) {
527
538
  const locale = useLocale();
528
539
  const presentation = () => props.block.presentation;
529
- const title = () => presentation()?.title ?? displayToolName(props.block.name);
540
+ const title = () => presentation()?.title ?? displayToolName(props.block.name, locale());
530
541
  return (
531
542
  <Chat.Activity
532
543
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
@@ -655,7 +666,7 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
655
666
  fallback={
656
667
  <ToolResultDisclosure
657
668
  blockId={props.block.id}
658
- name={displayToolName(props.block.name)}
669
+ name={displayToolName(props.block.name, locale())}
659
670
  toolName={props.block.name}
660
671
  args={props.block.args}
661
672
  result={props.block.result}
@@ -709,11 +720,14 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
709
720
  <TextEditorToolView turnId={props.turnId} block={props.block} active={props.active} />
710
721
  </Match>
711
722
  <Match when={status() === "running" || status() === "awaiting_client"}>
712
- <Chat.Activity label={displayToolName(props.block.name)} icon={aiToolIcon(props.block.name)} busy />
723
+ <Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
713
724
  </Match>
714
725
  </Switch>
715
726
  <Show when={hasCapabilityTable(props.block)}>
716
- <CapabilityTablePreview result={props.block.result} label={props.block.presentation?.title ?? displayToolName(props.block.name)} />
727
+ <CapabilityTablePreview
728
+ result={props.block.result}
729
+ label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
730
+ />
717
731
  </Show>
718
732
  </>
719
733
  );
@@ -742,7 +756,56 @@ export function AiTurnBlockView(props: { block: AiTurnBlock; turnId: string; str
742
756
  );
743
757
  }
744
758
 
745
- /** One step in the expanded work list: its title, target, and state, with input and output on demand. */
759
+ /** An alt text is a short description: the first line of the image description, clipped at a word. */
760
+ const altText = (description: string): string => {
761
+ const line =
762
+ description
763
+ .split("\n")
764
+ .find((entry) => entry.trim())
765
+ ?.trim() ?? "";
766
+ if (line.length <= 240) return line;
767
+ const clipped = line.slice(0, 240);
768
+ return `${clipped.slice(0, Math.max(clipped.lastIndexOf(" "), 160))}…`;
769
+ };
770
+
771
+ /**
772
+ * The image a view_image step looked at, as a small fixed-size thumbnail: it loads lazily through the chat's own file
773
+ * route, so it shows only what the viewer may read, and it keeps its box while it loads or fails. A host that opens
774
+ * chat files shows it larger on click; the button is named for that action and described by the image's alt text.
775
+ */
776
+ function StepImage(props: { path: string; src: string; description: string }) {
777
+ const actions = useAiChatActions();
778
+ const locale = useLocale();
779
+ const imageId = createUniqueId();
780
+ const [failed, setFailed] = createSignal(false);
781
+ const name = () => props.path.slice(props.path.lastIndexOf("/") + 1) || props.path;
782
+ const alt = () => altText(props.description);
783
+ const image = () => (
784
+ <Show when={!failed()} fallback={<i class="ti ti-photo-off text-lg text-dimmed" aria-hidden="true" />}>
785
+ <img id={imageId} src={props.src} alt={alt() || name()} loading="lazy" decoding="async" onError={() => setFailed(true)} />
786
+ </Show>
787
+ );
788
+ return (
789
+ <Show when={actions.onOpenFile} fallback={<div class="ai-step-image">{image()}</div>}>
790
+ {(open) => (
791
+ <button
792
+ type="button"
793
+ class="ai-step-image focus-ui"
794
+ aria-label={aiChatMessages(locale()).openImage({ name: name() })}
795
+ aria-describedby={!failed() && alt() ? imageId : undefined}
796
+ onClick={() => open()(props.path)}
797
+ >
798
+ {image()}
799
+ </button>
800
+ )}
801
+ </Show>
802
+ );
803
+ }
804
+
805
+ /**
806
+ * One step in the expanded work list: its title, target, state, and outcome, with input and output on demand. An app
807
+ * check names its outcome in words and with its own icon, so a passed and a failed check differ without colour.
808
+ */
746
809
  export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
747
810
  const locale = useLocale();
748
811
  const t = () => aiChatMessages(locale());
@@ -751,28 +814,54 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
751
814
  const waiting = () => props.block.status === "awaiting_client" || props.block.status === "awaiting_approval";
752
815
  const state = () =>
753
816
  waiting() ? t().stepWaiting : props.block.status === "rejected" ? t().stepRejected : isFailedTool(props.block) ? t().stepFailed : "";
817
+ const finished = () => props.block.status === "completed" && !props.block.isError;
818
+ const check = () => (finished() ? codeCheckOutcome(props.block.name, props.block.result) : null);
819
+ const outcome = () => {
820
+ const value = check();
821
+ if (!value) return "";
822
+ const result = value.passed ? t().checkPassed : value.errors > 0 ? t().checkFindings({ count: value.errors }) : t().checkNotPassed;
823
+ return value.warnings > 0 ? `${result} · ${t().checkWarnings({ count: value.warnings })}` : result;
824
+ };
825
+ const actions = useAiChatActions();
826
+ /** Only a host that resolves chat file URLs shows the image; without one there is no preview, not an empty box. */
827
+ const image = () => {
828
+ const viewed = finished() ? viewedChatImage(props.block.name, props.block.result) : null;
829
+ const src = viewed ? actions.fileUrl?.(viewed.path) : null;
830
+ return viewed && src ? { ...viewed, src } : null;
831
+ };
832
+ const icon = () => {
833
+ if (waiting()) return "ti ti-clock";
834
+ const value = check();
835
+ if (value) return value.passed ? "ti ti-circle-check" : "ti ti-alert-triangle";
836
+ return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
837
+ };
754
838
  return (
755
839
  <AiToolActivity
756
840
  blockId={props.block.id}
757
- label={props.block.presentation?.title ?? displayToolName(props.block.name)}
841
+ label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
758
842
  description={[
759
843
  props.block.status === "running" ? (props.block.progress ?? "") : "",
760
844
  typeof detail() === "string" ? String(detail()) : "",
761
845
  state(),
846
+ outcome(),
762
847
  ]
763
848
  .filter(Boolean)
764
849
  .join(" · ")}
765
- icon={waiting() ? "ti ti-clock" : aiToolIcon(props.block.name, props.block.presentation?.appIcon)}
850
+ icon={icon()}
766
851
  busy={props.busy && props.block.status === "running"}
767
852
  renderBody={() => (
768
- <div
769
- class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
770
- tabIndex={0}
771
- role="region"
772
- aria-label={t().toolInputOutput}
773
- >
774
- <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
775
- <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
853
+ <div class="flex min-w-0 flex-col gap-2">
854
+ <div
855
+ class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
856
+ tabIndex={0}
857
+ role="region"
858
+ aria-label={t().toolInputOutput}
859
+ >
860
+ <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
861
+ <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
862
+ </div>
863
+ {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
864
+ <Show when={image()}>{(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}</Show>
776
865
  </div>
777
866
  )}
778
867
  />
@@ -106,6 +106,7 @@ function SearchToolsView(props: { block: ToolBlock }) {
106
106
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
107
107
  const tools = () => records(result().tools);
108
108
  const t = useMessages();
109
+ const locale = useLocale();
109
110
  const query = () => text(args().query) || t().tools;
110
111
  return (
111
112
  <CompletedActivity block={props.block} label={t().searchTools({ query: query() })} description={t().found({ count: tools().length })}>
@@ -115,7 +116,8 @@ function SearchToolsView(props: { block: ToolBlock }) {
115
116
  {(tool) => (
116
117
  <ResultRow
117
118
  icon={tool.kind === "action" ? "ti-bolt" : tool.kind === "query" ? "ti-search" : aiToolIcon(text(tool.name))}
118
- title={text(tool.title) || displayToolName(text(tool.name))}
119
+ // The catalog titles a built-in tool in English, so the reader's language names it; a capability brings its own title.
120
+ title={tool.kind !== "builtin" && text(tool.title) ? text(tool.title) : displayToolName(text(tool.name), locale())}
119
121
  description={text(tool.description)}
120
122
  meta={text(tool.appId) || text(tool.kind)}
121
123
  />
@@ -11,6 +11,7 @@ import {
11
11
  type PendingAiVfsFile,
12
12
  readImageFile,
13
13
  readVfsFile,
14
+ VFS_FILE_MAX_BYTES,
14
15
  } from "./message-utils";
15
16
 
16
17
  export type { AiComposerAttachment } from "./message-utils";
@@ -37,6 +38,13 @@ export type AiComposerFileResult = {
37
38
 
38
39
  export const aiComposerFileAccept = FILE_INPUT_ACCEPT;
39
40
 
41
+ /**
42
+ * The `maxBytes` for `chooseFiles` while a composer holds `attached` attachments. Files from Cloud apps are read into
43
+ * the browser before `readAiComposerFiles` checks them, so together they stay within what the draft can still take:
44
+ * each free slot at most the largest file the composer attaches. 0 when every slot is taken.
45
+ */
46
+ export const aiComposerChooseMaxBytes = (attached: number): number => Math.max(0, MAX_ATTACHMENTS - attached) * VFS_FILE_MAX_BYTES;
47
+
40
48
  export const aiChatModelOptions = (profiles: readonly AiPublicModelProfile[]): ChatModelOption[] =>
41
49
  profiles.map((profile) => ({
42
50
  id: profile.id,
@@ -9,12 +9,18 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
9
9
  /**
10
10
  * The live chat timeline for a browser test, folded from wire events like the
11
11
  * controller does. `window.emit(event)` applies one event,
12
- * `window.steer(text)` appends a pending steer as the controller does, and
13
- * `window.reconnect(on)` sets whether the stream reconnects.
12
+ * `window.steer(text)` appends a pending steer as the controller does,
13
+ * `window.reconnect(on)` sets whether the stream reconnects,
14
+ * `window.continued` lists the messages that **Continue** sent, and
15
+ * `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
14
16
  */
15
17
  const [state, setState] = createStore(emptyProjection());
16
18
  const [reconnecting, setReconnecting] = createSignal(false);
19
+ const continued: string[] = [];
20
+ const opened: string[] = [];
17
21
  Object.assign(window, {
22
+ continued,
23
+ opened,
18
24
  reconnect: (on: boolean) => setReconnecting(on),
19
25
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
20
26
  steer: (text: string) =>
@@ -29,7 +35,13 @@ root.style.cssText = "display: flex; flex-direction: column; height: 100dvh; max
29
35
  document.body.append(root);
30
36
  render(
31
37
  () => (
32
- <AiChatActionsProvider actions={{}}>
38
+ <AiChatActionsProvider
39
+ actions={{
40
+ onContinueTurn: (message) => void continued.push(message),
41
+ fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
42
+ onOpenFile: (path) => void opened.push(path),
43
+ }}
44
+ >
33
45
  {(() => {
34
46
  const items = createAiChatTimeline({
35
47
  messages: createMemo(() => visibleMessages(state)),