@k2b/cloud 0.28.0 → 0.29.1

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 (112) hide show
  1. package/package.json +9 -6
  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/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. 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";
@@ -181,7 +183,14 @@ function ToolResultDisclosure(props: {
181
183
  );
182
184
  }
183
185
 
184
- export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
186
+ /** A decision the server accepted, for the call it answered. */
187
+ export type AiApprovalDecision = { callId: string; approved: boolean };
188
+
189
+ /**
190
+ * The pending approval: a calm tinted card that says what will happen, with its fields and the decision. `onDecided`
191
+ * hears an accepted decision with the call it answered, so the turn can show its receipt in the card's place right away.
192
+ */
193
+ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onDecided?: (decision: AiApprovalDecision) => void }) {
185
194
  const actions = useAiChatActions();
186
195
  const locale = useLocale();
187
196
  const t = () => aiChatMessages(locale());
@@ -190,17 +199,24 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
190
199
  const actionDisabled = () => actions.actionDisabled?.() ?? false;
191
200
  const [submitted, setSubmitted] = createSignal(false);
192
201
  const [detailsOpen, setDetailsOpen] = createSignal(false);
202
+ // The call this card answered: a later approval for the same block may already have arrived when the server accepts.
203
+ let decision: AiApprovalDecision | null = null;
193
204
  const approval = mutation.create<void, { approved: boolean; remember?: "always" }>({
194
205
  mutation: async (input) => {
195
206
  if (!actions.onApproval) throw new Error("Approval is unavailable.");
196
207
  await actions.onApproval(request(), input);
197
208
  },
198
- onSuccess: () => setSubmitted(true),
209
+ onSuccess: () => {
210
+ setSubmitted(true);
211
+ if (decision) props.onDecided?.(decision);
212
+ },
199
213
  });
200
214
  const submit = (input: { approved: boolean; remember?: "always" }) => {
201
- if (!actionDisabled() && !approval.loading()) void approval.mutate(input);
215
+ if (actionDisabled() || approval.loading()) return;
216
+ decision = { callId: props.block.callId, approved: input.approved };
217
+ void approval.mutate(input);
202
218
  };
203
- const title = () => props.block.presentation?.title ?? displayToolName(props.block.name);
219
+ const title = () => props.block.presentation?.title ?? displayToolName(props.block.name, locale());
204
220
  const ownerName = () => props.block.presentation?.appName ?? t().assistant;
205
221
  const description = () => {
206
222
  const reviewMessage = props.block.approval?.review?.message.trim();
@@ -237,199 +253,166 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
237
253
  download: t().download,
238
254
  })[rel];
239
255
  const detailData = () => (isStructuredDataValue(props.block.args) ? props.block.args : undefined);
240
- const appAccent = () => props.block.presentation?.appAccent;
241
256
  const detailsId = `approval-details-${props.block.callId}`;
242
257
  return (
243
- <div class="w-full">
244
- <section
245
- class={`w-full overflow-hidden rounded-xl border border-[var(--k2b-border)] bg-[var(--k2b-surface)] text-sm text-primary ${appAccent() ? "app-accent-scope" : ""}`}
246
- style={{ "--app-accent": appAccent() }}
247
- aria-label={t().approvalRequired({ title: title() })}
248
- >
249
- <div class="p-4">
250
- <div class="flex min-w-0 items-center gap-3">
251
- <span
252
- class="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-base leading-none"
253
- style={{
254
- color: appAccent() ? "var(--ui-app-accent-text)" : "var(--k2b-ai-accent)",
255
- background: appAccent()
256
- ? "color-mix(in srgb, var(--app-accent) 10%, var(--k2b-surface))"
257
- : "color-mix(in srgb, var(--k2b-ai-accent) 10%, var(--k2b-surface))",
258
- }}
259
- aria-hidden="true"
260
- >
261
- <i class={`${aiToolIcon(props.block.name, props.block.presentation?.appIcon)} leading-none`} />
262
- </span>
263
- <div class="min-w-0">
264
- <h3 class="truncate text-sm font-semibold leading-5 text-primary">
265
- {ownerName()} · {title()}
266
- </h3>
267
- <p class="text-xs text-dimmed">{t().action}</p>
268
- </div>
269
- </div>
270
- <Show when={reviewLines().length > 0}>
271
- <div class="mt-4 flex flex-col gap-1 text-xs leading-5 text-secondary">
272
- <For each={reviewLines()}>
273
- {(line) => (
274
- <p class="whitespace-pre-wrap">
275
- <Show when={line.label}>{(label) => <strong class="font-semibold text-primary">{label()}: </strong>}</Show>
276
- {line.value}
277
- </p>
278
- )}
279
- </For>
280
- </div>
281
- </Show>
282
- <Show when={props.block.approval?.review}>
283
- <div class="mt-4 flex flex-col gap-4 text-xs leading-5 text-secondary">
284
- <p class="whitespace-pre-wrap">{description()}</p>
285
- <Show when={inlineReviewDetails().length > 0}>
286
- <dl class="grid gap-x-5 gap-y-1.5 sm:grid-cols-[max-content_minmax(0,1fr)]">
287
- <For each={inlineReviewDetails()}>
288
- {(detail) => (
289
- <>
290
- <dt class="font-semibold text-primary">{detail.label}</dt>
291
- <dd class="min-w-0 whitespace-pre-wrap break-words">
292
- <ReviewDetailValue detail={detail} />
293
- </dd>
294
- </>
295
- )}
296
- </For>
297
- </dl>
258
+ <section class="ai-approval" aria-label={t().approvalRequired({ title: title() })}>
259
+ <div class="ai-approval__head">
260
+ <i class={`ai-approval__icon ${aiToolIcon(props.block.name, props.block.presentation?.appIcon)}`} aria-hidden="true" />
261
+ <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>
266
+ </div>
267
+ </div>
268
+ <Show when={reviewLines().length > 0}>
269
+ <div class="ai-approval__text">
270
+ <For each={reviewLines()}>
271
+ {(line) => (
272
+ <p class="whitespace-pre-wrap">
273
+ <Show when={line.label}>{(label) => <strong class="ai-approval__label">{label()}: </strong>}</Show>
274
+ {line.value}
275
+ </p>
276
+ )}
277
+ </For>
278
+ </div>
279
+ </Show>
280
+ <Show when={props.block.approval?.review}>
281
+ <p class="ai-approval__text whitespace-pre-wrap">{description()}</p>
282
+ <Show when={inlineReviewDetails().length > 0}>
283
+ <dl class="ai-approval__fields">
284
+ <For each={inlineReviewDetails()}>
285
+ {(detail) => (
286
+ <>
287
+ <dt>{detail.label}</dt>
288
+ <dd class="whitespace-pre-wrap">
289
+ <ReviewDetailValue detail={detail} />
290
+ </dd>
291
+ </>
292
+ )}
293
+ </For>
294
+ </dl>
295
+ </Show>
296
+ <For each={blockReviewDetails()}>
297
+ {(detail) => (
298
+ <section class="ai-approval__text min-w-0" aria-label={detail.label}>
299
+ <h4 class="ai-approval__label mb-1">{detail.label}</h4>
300
+ <Show
301
+ when={detail.format === "markdown"}
302
+ fallback={
303
+ <pre
304
+ class="ai-approval__preview whitespace-pre-wrap break-words font-sans"
305
+ role="region"
306
+ tabIndex={0}
307
+ aria-label={t().contentOf({ label: detail.label })}
308
+ >
309
+ <ReviewDetailValue detail={detail} />
310
+ </pre>
311
+ }
312
+ >
313
+ <div class="ai-approval__preview" role="region" tabIndex={0} aria-label={detail.label}>
314
+ <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
315
+ </div>
298
316
  </Show>
299
- <For each={blockReviewDetails()}>
300
- {(detail) => (
301
- <section class="min-w-0" aria-label={detail.label}>
302
- <h4 class="mb-1.5 font-semibold text-primary">{detail.label}</h4>
303
- <Show
304
- when={detail.format === "markdown"}
305
- fallback={
306
- <pre
307
- class="max-h-72 overflow-auto whitespace-pre-wrap break-words pr-2 font-sans text-xs leading-5 text-secondary"
308
- role="region"
309
- tabIndex={0}
310
- aria-label={t().contentOf({ label: detail.label })}
311
- >
312
- <ReviewDetailValue detail={detail} />
313
- </pre>
314
- }
315
- >
316
- <div
317
- class="-mx-2 max-h-72 overflow-auto px-2 text-xs text-secondary"
318
- role="region"
319
- tabIndex={0}
320
- aria-label={detail.label}
321
- >
322
- <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
323
- </div>
324
- </Show>
325
- </section>
326
- )}
327
- </For>
328
- </div>
317
+ </section>
318
+ )}
319
+ </For>
320
+ </Show>
321
+ <Show when={detailsOpen()}>
322
+ <div id={detailsId} class="w-full min-w-0" role="region" aria-label={t().detailsFor({ title: title() })}>
323
+ <Show when={detailData()} fallback={<pre class="ai-approval__preview text-[11px]">{jsonPreview(props.block.args)}</pre>}>
324
+ {(data) => <StructuredDataPreview data={data()} class="w-full" />}
329
325
  </Show>
330
326
  </div>
331
- {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
332
- <footer class="flex min-h-12 flex-wrap items-center gap-2 px-4 pb-3" data-ai-approval-footer>
333
- {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
334
- <span class="k2b-sr-only" role="status" aria-live="polite">
335
- {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
336
- </span>
337
- <Show when={reviewLinks().length > 0}>
338
- <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
339
- <For each={reviewLinks()}>
340
- {(link) => (
341
- <ButtonLink href={link.href} target="_blank" rel="noopener noreferrer" size="xs" variant="ghost">
342
- {link.title ?? reviewLinkTitle(link.rel)}
343
- </ButtonLink>
344
- )}
345
- </For>
346
- </nav>
347
- </Show>
348
- <Show when={approval.error()}>
349
- <p class="text-xs text-red-700 dark:text-red-300" role="alert">
350
- {t().approvalFailed}
351
- </p>
352
- </Show>
353
- <div class="ml-auto shrink-0">
354
- <Show
355
- when={pending()}
356
- fallback={
357
- <span class="text-xs font-medium text-secondary">
358
- {title()} · {props.block.status === "rejected" ? t().rejected : t().approved}
359
- </span>
360
- }
361
- >
362
- <Show when={actions.onApproval} fallback={<span class="text-xs font-medium text-secondary">{t().approvalUnavailable}</span>}>
327
+ </Show>
328
+ {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
329
+ <footer class="ai-approval__foot" data-ai-approval-footer>
330
+ {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
331
+ <span class="k2b-sr-only" role="status" aria-live="polite">
332
+ {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
333
+ </span>
334
+ <Show when={reviewLinks().length > 0}>
335
+ <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
336
+ <For each={reviewLinks()}>
337
+ {(link) => (
338
+ <ButtonLink href={link.href} target="_blank" rel="noopener noreferrer" size="xs" variant="ghost">
339
+ {link.title ?? reviewLinkTitle(link.rel)}
340
+ </ButtonLink>
341
+ )}
342
+ </For>
343
+ </nav>
344
+ </Show>
345
+ <Show when={approval.error()}>
346
+ <p class="text-xs text-red-700 dark:text-red-300" role="alert">
347
+ {t().approvalFailed}
348
+ </p>
349
+ </Show>
350
+ <div class="ai-approval__actions">
351
+ <Show
352
+ when={pending()}
353
+ fallback={
354
+ <span class="ai-approval__state">
355
+ {title()} · {props.block.status === "rejected" ? t().rejected : t().approved}
356
+ </span>
357
+ }
358
+ >
359
+ <Show when={actions.onApproval} fallback={<span class="ai-approval__state">{t().approvalUnavailable}</span>}>
360
+ <Show
361
+ when={!actionDisabled()}
362
+ fallback={
363
+ <span class="ai-approval__state">
364
+ <i class="ti ti-player-stop" aria-hidden="true" />
365
+ {t().stoppingResponse}
366
+ </span>
367
+ }
368
+ >
363
369
  <Show
364
- when={!actionDisabled()}
370
+ when={!submitted()}
365
371
  fallback={
366
- <span class="inline-flex items-center gap-1 text-xs font-medium text-secondary">
367
- <i class="ti ti-player-stop" aria-hidden="true" />
368
- {t().stoppingResponse}
372
+ <span class="ai-approval__state">
373
+ <i class="ti ti-check" aria-hidden="true" />
374
+ {t().submitted}
369
375
  </span>
370
376
  }
371
377
  >
372
- <Show
373
- when={!approval.loading() && !submitted()}
374
- fallback={
375
- <span class="inline-flex items-center gap-1 text-xs font-medium text-secondary">
376
- <i class={`ti ${submitted() ? "ti-check" : "ti-loader-2 animate-spin"}`} aria-hidden="true" />
377
- {submitted() ? t().submitted : t().submitting}
378
- </span>
379
- }
378
+ {/* The buttons stay while the decision is sent, so the card does not change its height before it
379
+ becomes its receipt. */}
380
+ <Button size="sm" variant="ghost" disabled={approval.loading()} onClick={() => submit({ approved: false })}>
381
+ {t().reject}
382
+ </Button>
383
+ <SplitButton
384
+ size="sm"
385
+ variant="ai"
386
+ loading={approval.loading()}
387
+ onClick={() => submit({ approved: true })}
388
+ menuLabel={t().moreOptions({ title: title() })}
389
+ menuPosition="bottom-right"
390
+ items={[
391
+ {
392
+ label: detailsOpen() ? t().hideDetails : t().details,
393
+ icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
394
+ action: () => setDetailsOpen((open) => !open),
395
+ },
396
+ ...(props.block.approval?.allowAlways
397
+ ? [
398
+ {
399
+ label: t().alwaysApprove,
400
+ icon: "ti ti-shield-check",
401
+ action: () => submit({ approved: true, remember: "always" }),
402
+ },
403
+ ]
404
+ : []),
405
+ ]}
380
406
  >
381
- <div class="flex flex-wrap justify-end gap-1">
382
- <Button size="xs" variant="ghost" onClick={() => submit({ approved: false })}>
383
- {t().reject}
384
- </Button>
385
- <SplitButton
386
- size="xs"
387
- variant="ai"
388
- onClick={() => submit({ approved: true })}
389
- menuLabel={t().moreOptions({ title: title() })}
390
- menuPosition="bottom-right"
391
- items={[
392
- {
393
- label: detailsOpen() ? t().hideDetails : t().details,
394
- icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
395
- action: () => setDetailsOpen((open) => !open),
396
- },
397
- ...(props.block.approval?.allowAlways
398
- ? [
399
- {
400
- label: t().alwaysApprove,
401
- icon: "ti ti-shield-check",
402
- action: () => submit({ approved: true, remember: "always" }),
403
- },
404
- ]
405
- : []),
406
- ]}
407
- >
408
- {title()}
409
- </SplitButton>
410
- </div>
411
- </Show>
407
+ {title()}
408
+ </SplitButton>
412
409
  </Show>
413
410
  </Show>
414
411
  </Show>
415
- </div>
416
- </footer>
417
- </section>
418
- <Show when={detailsOpen()}>
419
- <div id={detailsId} class="mt-2 w-full" role="region" aria-label={t().detailsFor({ title: title() })}>
420
- <Show
421
- when={detailData()}
422
- fallback={
423
- <pre class="max-h-40 overflow-auto rounded-md bg-white/55 p-2 text-[11px] text-primary dark:bg-black/20">
424
- {jsonPreview(props.block.args)}
425
- </pre>
426
- }
427
- >
428
- {(data) => <StructuredDataPreview data={data()} class="w-full" />}
429
412
  </Show>
430
413
  </div>
431
- </Show>
432
- </div>
414
+ </footer>
415
+ </section>
433
416
  );
434
417
  }
435
418
 
@@ -535,7 +518,7 @@ function CapabilityToolView(props: { block: ToolBlock }) {
535
518
  function RejectedToolView(props: { block: ToolBlock }) {
536
519
  const locale = useLocale();
537
520
  const presentation = () => props.block.presentation;
538
- const title = () => presentation()?.title ?? displayToolName(props.block.name);
521
+ const title = () => presentation()?.title ?? displayToolName(props.block.name, locale());
539
522
  return (
540
523
  <Chat.Activity
541
524
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
@@ -664,7 +647,7 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
664
647
  fallback={
665
648
  <ToolResultDisclosure
666
649
  blockId={props.block.id}
667
- name={displayToolName(props.block.name)}
650
+ name={displayToolName(props.block.name, locale())}
668
651
  toolName={props.block.name}
669
652
  args={props.block.args}
670
653
  result={props.block.result}
@@ -718,11 +701,14 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
718
701
  <TextEditorToolView turnId={props.turnId} block={props.block} active={props.active} />
719
702
  </Match>
720
703
  <Match when={status() === "running" || status() === "awaiting_client"}>
721
- <Chat.Activity label={displayToolName(props.block.name)} icon={aiToolIcon(props.block.name)} busy />
704
+ <Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
722
705
  </Match>
723
706
  </Switch>
724
707
  <Show when={hasCapabilityTable(props.block)}>
725
- <CapabilityTablePreview result={props.block.result} label={props.block.presentation?.title ?? displayToolName(props.block.name)} />
708
+ <CapabilityTablePreview
709
+ result={props.block.result}
710
+ label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
711
+ />
726
712
  </Show>
727
713
  </>
728
714
  );
@@ -751,7 +737,56 @@ export function AiTurnBlockView(props: { block: AiTurnBlock; turnId: string; str
751
737
  );
752
738
  }
753
739
 
754
- /** One step in the expanded work list: its title, target, and state, with input and output on demand. */
740
+ /** An alt text is a short description: the first line of the image description, clipped at a word. */
741
+ const altText = (description: string): string => {
742
+ const line =
743
+ description
744
+ .split("\n")
745
+ .find((entry) => entry.trim())
746
+ ?.trim() ?? "";
747
+ if (line.length <= 240) return line;
748
+ const clipped = line.slice(0, 240);
749
+ return `${clipped.slice(0, Math.max(clipped.lastIndexOf(" "), 160))}…`;
750
+ };
751
+
752
+ /**
753
+ * The image a view_image step looked at, as a small fixed-size thumbnail: it loads lazily through the chat's own file
754
+ * route, so it shows only what the viewer may read, and it keeps its box while it loads or fails. A host that opens
755
+ * chat files shows it larger on click; the button is named for that action and described by the image's alt text.
756
+ */
757
+ function StepImage(props: { path: string; src: string; description: string }) {
758
+ const actions = useAiChatActions();
759
+ const locale = useLocale();
760
+ const imageId = createUniqueId();
761
+ const [failed, setFailed] = createSignal(false);
762
+ const name = () => props.path.slice(props.path.lastIndexOf("/") + 1) || props.path;
763
+ const alt = () => altText(props.description);
764
+ const image = () => (
765
+ <Show when={!failed()} fallback={<i class="ti ti-photo-off text-lg text-dimmed" aria-hidden="true" />}>
766
+ <img id={imageId} src={props.src} alt={alt() || name()} loading="lazy" decoding="async" onError={() => setFailed(true)} />
767
+ </Show>
768
+ );
769
+ return (
770
+ <Show when={actions.onOpenFile} fallback={<div class="ai-step-image">{image()}</div>}>
771
+ {(open) => (
772
+ <button
773
+ type="button"
774
+ class="ai-step-image focus-ui"
775
+ aria-label={aiChatMessages(locale()).openImage({ name: name() })}
776
+ aria-describedby={!failed() && alt() ? imageId : undefined}
777
+ onClick={() => open()(props.path)}
778
+ >
779
+ {image()}
780
+ </button>
781
+ )}
782
+ </Show>
783
+ );
784
+ }
785
+
786
+ /**
787
+ * One step in the expanded work list: its title, target, state, and outcome, with input and output on demand. An app
788
+ * check names its outcome in words and with its own icon, so a passed and a failed check differ without colour.
789
+ */
755
790
  export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
756
791
  const locale = useLocale();
757
792
  const t = () => aiChatMessages(locale());
@@ -760,28 +795,55 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
760
795
  const waiting = () => props.block.status === "awaiting_client" || props.block.status === "awaiting_approval";
761
796
  const state = () =>
762
797
  waiting() ? t().stepWaiting : props.block.status === "rejected" ? t().stepRejected : isFailedTool(props.block) ? t().stepFailed : "";
798
+ const finished = () => props.block.status === "completed" && !props.block.isError;
799
+ const check = () => (finished() ? codeCheckOutcome(props.block.name, props.block.result) : null);
800
+ const outcome = () => {
801
+ const value = check();
802
+ if (!value) return "";
803
+ const result = value.passed ? t().checkPassed : value.errors > 0 ? t().checkFindings({ count: value.errors }) : t().checkNotPassed;
804
+ return value.warnings > 0 ? `${result} · ${t().checkWarnings({ count: value.warnings })}` : result;
805
+ };
806
+ const actions = useAiChatActions();
807
+ /** Only a host that resolves chat file URLs shows the image; without one there is no preview, not an empty box. */
808
+ const image = () => {
809
+ const viewed = finished() ? viewedChatImage(props.block.name, props.block.result) : null;
810
+ const src = viewed ? actions.fileUrl?.(viewed.path) : null;
811
+ return viewed && src ? { ...viewed, src } : null;
812
+ };
813
+ const icon = () => {
814
+ if (waiting()) return "ti ti-clock";
815
+ const value = check();
816
+ if (value) return value.passed ? "ti ti-circle-check" : "ti ti-alert-triangle";
817
+ return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
818
+ };
763
819
  return (
764
820
  <AiToolActivity
765
821
  blockId={props.block.id}
766
- label={props.block.presentation?.title ?? displayToolName(props.block.name)}
822
+ label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
767
823
  description={[
768
824
  props.block.status === "running" ? (props.block.progress ?? "") : "",
769
825
  typeof detail() === "string" ? String(detail()) : "",
770
826
  state(),
827
+ outcome(),
771
828
  ]
772
829
  .filter(Boolean)
773
830
  .join(" · ")}
774
- icon={waiting() ? "ti ti-clock" : aiToolIcon(props.block.name, props.block.presentation?.appIcon)}
831
+ icon={icon()}
775
832
  busy={props.busy && props.block.status === "running"}
833
+ bodyInset={false}
776
834
  renderBody={() => (
777
- <div
778
- class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
779
- tabIndex={0}
780
- role="region"
781
- aria-label={t().toolInputOutput}
782
- >
783
- <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
784
- <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
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} />
844
+ </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>
785
847
  </div>
786
848
  )}
787
849
  />
@@ -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,