@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
@@ -0,0 +1,21 @@
1
+ import type { AiTurnError } from "../types";
2
+ import { aiChatMessages } from "./messages";
3
+
4
+ /**
5
+ * Whether a new message can pick up the work. A full context, a used-up quota, or lost access would fail the next
6
+ * turn the same way; the reason then names the next step itself.
7
+ */
8
+ export const aiTurnErrorCanContinue = (error: AiTurnError): boolean =>
9
+ error.code !== "context_full" && error.code !== "quota_exhausted" && error.code !== "not_allowed";
10
+
11
+ /** What the chat shows under the title: the reason, and that the results so far are kept when the work can go on. */
12
+ export const aiTurnErrorDescription = (error: AiTurnError, locale: string): string => {
13
+ const t = aiChatMessages(locale);
14
+ return aiTurnErrorCanContinue(error) ? `${t.turnErrorReason(error)} ${t.turnErrorKept}` : t.turnErrorReason(error);
15
+ };
16
+
17
+ /** The turn's stored error for readers without the chat view, such as `cld`: the description and how to go on. */
18
+ export const aiTurnErrorText = (error: AiTurnError, locale: string): string =>
19
+ aiTurnErrorCanContinue(error)
20
+ ? `${aiTurnErrorDescription(error, locale)} ${aiChatMessages(locale).turnErrorContinueHint}`
21
+ : aiTurnErrorDescription(error, locale);
@@ -1,4 +1,5 @@
1
1
  import { type AiTurnBlock, isRenderableTurnBlock } from "../protocol";
2
+ import type { AiStoredMessage } from "../types";
2
3
  import { hasCapabilityTable } from "./capability-result";
3
4
  import { isCardToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
4
5
  import { isFailedTool } from "./tool-groups";
@@ -139,3 +140,14 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
139
140
  waitingFor,
140
141
  };
141
142
  }
143
+
144
+ /** The loop ending is authoritative; partial messages also carry their own failed or interrupted ending. */
145
+ export const storedPhase = (entries: readonly Pick<AiStoredMessage, "loopDoneReason" | "stopReason">[]): AiTurnPhase => {
146
+ const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
147
+ if (reason === "aborted") return "stopped";
148
+ if (reason && reason !== "stop") return "failed";
149
+ const stop = entries.findLast((entry) => entry.stopReason)?.stopReason;
150
+ if (stop === "error") return "failed";
151
+ if (stop === "interrupted" || stop === "aborted") return "stopped";
152
+ return "completed";
153
+ };
@@ -1,4 +1,4 @@
1
- import { announce, Button, ButtonLink, Chat, useLocale } from "@k2b/ui";
1
+ import { announce, Button, ButtonLink, Chat, NoticeCard, useLocale } from "@k2b/ui";
2
2
  import {
3
3
  type Accessor,
4
4
  createEffect,
@@ -16,6 +16,7 @@ import {
16
16
  } from "solid-js";
17
17
  import { markdown } from "../../shared";
18
18
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
19
+ import type { AiTurnError } from "../types";
19
20
  import { ApprovalBlockView, CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
20
21
  import { CapabilityTablePreview } from "./capability-table";
21
22
  import { PresentToolBlock } from "./file-tools";
@@ -25,6 +26,7 @@ import { aiChatMessages } from "./messages";
25
26
  import { AssistantMarkdownBlock } from "./primitives";
26
27
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
27
28
  import { type AiWorkEntry, countWorkSteps, groupWorkBlocks, summarizeToolGroup } from "./tool-groups";
29
+ import { aiTurnErrorCanContinue, aiTurnErrorDescription } from "./turn-error";
28
30
  import type { AiTurnAction, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
29
31
  import { CloudCardBlock } from "./visual-tools";
30
32
 
@@ -53,6 +55,10 @@ export type AiTurnSegment = {
53
55
  * it in a row at the end of its last segment.
54
56
  */
55
57
  reconnect?: "line" | "row";
58
+ /** Why the turn failed, shown in a notice at its end. Only the last segment of a failed turn carries it. */
59
+ error?: AiTurnError | null;
60
+ /** The failed turn is the chat's newest and nothing runs, so a new message can pick up its work. */
61
+ continuable?: boolean;
56
62
  };
57
63
 
58
64
  const isLive = (phase: AiTurnPhase) => phase === "running" || phase === "waiting";
@@ -121,7 +127,8 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
121
127
  if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
122
128
  return withTarget(t.stepWritingCode);
123
129
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
124
- if (["code_inspect", "code_interact"].includes(name)) return t.stepCheckingApp;
130
+ if (name === "code_check") return t.stepCheckingApp;
131
+ if (name === "code_inspect") return t.stepCheckingRun;
125
132
  if (name.startsWith("code_")) return t.stepCode;
126
133
  if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
127
134
  if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
@@ -165,7 +172,8 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
165
172
  const duration = worked();
166
173
  const short = duration === null ? "" : formatWorkDuration(duration, t());
167
174
  if (segment().phase === "stopped") return t().workedStopped({ duration: short });
168
- if (segment().phase === "failed") return t().workedInterrupted({ duration: short });
175
+ // A failed turn with a stored reason says it in its notice; the line then reads like any finished turn.
176
+ if (segment().phase === "failed" && !segment().error) return t().workedInterrupted({ duration: short });
169
177
  return short ? t().workedFor({ duration: short }) : t().worked;
170
178
  };
171
179
  const ariaLabel = () => {
@@ -175,7 +183,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
175
183
  return t().workedLabel({
176
184
  duration: duration === null ? "" : formatWorkDuration(duration, t(), true),
177
185
  steps: layout().steps ? steps() : "",
178
- ending: phase === "stopped" || phase === "failed" ? phase : null,
186
+ ending: phase === "stopped" || (phase === "failed" && !segment().error) ? phase : null,
179
187
  });
180
188
  };
181
189
  const trailing = () => {
@@ -362,6 +370,7 @@ function ResultLinks(props: { links: Record<string, unknown>[] }) {
362
370
 
363
371
  /** The summary and links of a table result, unless its receipt in place 4 already shows them. */
364
372
  function CapabilityResultRow(props: { block: ToolBlock }) {
373
+ const locale = useLocale();
365
374
  const block = () => props.block;
366
375
  const summary = () => resultSummary(block());
367
376
  const links = () => resultLinks(block());
@@ -370,7 +379,7 @@ function CapabilityResultRow(props: { block: ToolBlock }) {
370
379
  <Chat.Activity
371
380
  icon={aiToolIcon(block().name, block().presentation?.appIcon)}
372
381
  accent={block().presentation?.appAccent}
373
- label={summary() || (block().presentation?.title ?? displayToolName(block().name))}
382
+ label={summary() || (block().presentation?.title ?? displayToolName(block().name, locale()))}
374
383
  trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
375
384
  />
376
385
  </Show>
@@ -378,6 +387,7 @@ function CapabilityResultRow(props: { block: ToolBlock }) {
378
387
  }
379
388
 
380
389
  function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; receipt: Accessor<boolean> }) {
390
+ const locale = useLocale();
381
391
  const actions = useAiChatActions();
382
392
  const block = () => props.result()?.block;
383
393
  return (
@@ -407,7 +417,10 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
407
417
  <Show when={!props.receipt()}>
408
418
  <CapabilityResultRow block={current()} />
409
419
  </Show>
410
- <CapabilityTablePreview result={current().result} label={current().presentation?.title ?? displayToolName(current().name)} />
420
+ <CapabilityTablePreview
421
+ result={current().result}
422
+ label={current().presentation?.title ?? displayToolName(current().name, locale())}
423
+ />
411
424
  </Match>
412
425
  </Switch>
413
426
  )}
@@ -419,7 +432,7 @@ function AiReceipt(props: { action: AiTurnAction; stopped: boolean }) {
419
432
  const locale = useLocale();
420
433
  const t = () => aiChatMessages(locale());
421
434
  const block = () => props.action.block;
422
- const title = () => block().presentation?.title ?? displayToolName(block().name);
435
+ const title = () => block().presentation?.title ?? displayToolName(block().name, locale());
423
436
  const links = () => (props.action.state === "done" ? resultLinks(block()) : []);
424
437
  const label = () => {
425
438
  const state = props.action.state;
@@ -458,7 +471,9 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
458
471
  const current = action();
459
472
  if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
460
473
  announce(
461
- aiChatMessages(locale()).approvalNeeded({ title: current.block.presentation?.title ?? displayToolName(current.block.name) }),
474
+ aiChatMessages(locale()).approvalNeeded({
475
+ title: current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
476
+ }),
462
477
  );
463
478
  }),
464
479
  );
@@ -493,7 +508,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
493
508
  <Switch
494
509
  fallback={
495
510
  <Chat.Activity
496
- label={current().block.presentation?.title ?? displayToolName(current().block.name)}
511
+ label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
497
512
  icon={aiToolIcon(current().block.name)}
498
513
  busy={current().block.status === "awaiting_client"}
499
514
  />
@@ -509,7 +524,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
509
524
  </Match>
510
525
  <Match when={current().state === "running"}>
511
526
  <Chat.Activity
512
- label={current().block.presentation?.title ?? displayToolName(current().block.name)}
527
+ label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
513
528
  icon={aiToolIcon(current().block.name, current().block.presentation?.appIcon)}
514
529
  accent={current().block.presentation?.appAccent}
515
530
  busy
@@ -522,6 +537,37 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
522
537
  );
523
538
  }
524
539
 
540
+ /**
541
+ * Why a turn failed and what comes next, at its end. "Continue" sends a visible message through the host's normal
542
+ * send path, so the person sees what was asked; it is offered only for the chat's newest turn and only when a new
543
+ * turn can finish the work.
544
+ */
545
+ function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean }) {
546
+ const locale = useLocale();
547
+ const t = () => aiChatMessages(locale());
548
+ const actions = useAiChatActions();
549
+ const [continuing, setContinuing] = createSignal(false);
550
+ const canContinue = () => props.continuable && aiTurnErrorCanContinue(props.error) && Boolean(actions.onContinueTurn);
551
+ const continueTurn = async () => {
552
+ if (continuing()) return;
553
+ setContinuing(true);
554
+ try {
555
+ await actions.onContinueTurn?.(t().continueTurnMessage);
556
+ } finally {
557
+ setContinuing(false);
558
+ }
559
+ };
560
+ return (
561
+ <NoticeCard tone="danger" class="ai-turn__notice" title={t().turnErrorTitle} detail={aiTurnErrorDescription(props.error, locale())}>
562
+ <Show when={canContinue()}>
563
+ <Button variant="text" size="sm" loading={continuing()} disabled={actions.actionDisabled?.()} onClick={() => void continueTurn()}>
564
+ <i class="ti ti-player-play" aria-hidden="true" /> {t().continueTurn}
565
+ </Button>
566
+ </Show>
567
+ </NoticeCard>
568
+ );
569
+ }
570
+
525
571
  /**
526
572
  * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
527
573
  * is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
@@ -605,6 +651,16 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
605
651
  const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
606
652
  const actionIds = createMemo(() => [...actions().keys()]);
607
653
  const live = () => isLive(props.segment().phase);
654
+ // Screen readers hear once that the turn they followed failed and why; history read later is not announced.
655
+ createEffect(
656
+ on(live, (now, before) => {
657
+ const error = props.segment().error;
658
+ if (before && !now && error) {
659
+ const t = aiChatMessages(locale());
660
+ announce(`${t.turnErrorTitle} ${aiTurnErrorDescription(error, locale())}`);
661
+ }
662
+ }),
663
+ );
608
664
  return (
609
665
  <AiToolDisclosureProvider state={props.disclosureState}>
610
666
  <div class="ai-turn">
@@ -638,6 +694,9 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
638
694
  {/* Calm by design: no busy sweep while the turn reconnects. */}
639
695
  <Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
640
696
  </Show>
697
+ <Show when={props.segment().error}>
698
+ {(error) => <AiTurnErrorNotice error={error()} continuable={props.segment().continuable === true} />}
699
+ </Show>
641
700
  </div>
642
701
  </AiToolDisclosureProvider>
643
702
  );
@@ -13,11 +13,14 @@ import {
13
13
  userVisibleTextFromMessage,
14
14
  vfsAttachmentsFromMessage,
15
15
  } from "./message-utils";
16
+ import { aiChatMessages } from "./messages";
16
17
 
17
18
  const openModifyRetryDialog = (
18
19
  entry: AiStoredMessage,
19
20
  onRetryMessage: (entry: AiStoredMessage, input?: AiRetryMessageInput) => void | Promise<void>,
21
+ locale: string,
20
22
  ) => {
23
+ const t = aiChatMessages(locale);
21
24
  void dialogCore.open<void>((close) => {
22
25
  const [draft, setDraft] = createSignal(userVisibleTextFromMessage(entry.message));
23
26
  const content = () => userContentWithEditedVisibleText(entry.message, draft());
@@ -34,11 +37,11 @@ const openModifyRetryDialog = (
34
37
 
35
38
  return (
36
39
  <PanelDialog>
37
- <PanelDialog.Header title="Edit and try again" icon="ti ti-pencil" close={close} />
40
+ <PanelDialog.Header title={t.retryTitle} icon="ti ti-pencil" close={close} />
38
41
  <PanelDialog.Body>
39
42
  <TextInput
40
- label="Prompt"
41
- description="Attachments from the original message stay attached."
43
+ label={t.retryPrompt}
44
+ description={t.retryPromptHint}
42
45
  multiline
43
46
  lines={8}
44
47
  value={draft}
@@ -47,18 +50,18 @@ const openModifyRetryDialog = (
47
50
  />
48
51
  <Show when={retryMutation.error()}>
49
52
  <p class="text-xs text-red-600 dark:text-red-400" role="alert">
50
- Could not retry this message. Your changes are still here.
53
+ {t.retryFailed}
51
54
  </p>
52
55
  </Show>
53
56
  </PanelDialog.Body>
54
57
  <PanelDialog.Footer>
55
58
  <div class="ml-auto flex items-center gap-2">
56
59
  <Button type="button" variant="secondary" size="sm" onClick={() => close()}>
57
- Cancel
60
+ {t.cancel}
58
61
  </Button>
59
62
  <Button type="button" variant="ai" size="sm" disabled={!canRetry()} onClick={retry}>
60
63
  <i class={`ti ${retryMutation.loading() ? "ti-loader-2 animate-spin" : "ti-refresh"}`} aria-hidden="true" />
61
- {retryMutation.loading() ? "Trying again" : "Try again"}
64
+ {retryMutation.loading() ? t.retrying : t.actionTryAgain}
62
65
  </Button>
63
66
  </div>
64
67
  </PanelDialog.Footer>
@@ -93,38 +96,39 @@ export const aiUserMessageAttachments = (entry: AiStoredMessage, actions: AiChat
93
96
  })),
94
97
  ];
95
98
 
96
- export const createAiUserMessageActions = (entry: AiStoredMessage, actions: AiChatActions): ChatAction[] => {
99
+ export const createAiUserMessageActions = (entry: AiStoredMessage, actions: AiChatActions, locale: string): ChatAction[] => {
100
+ const t = aiChatMessages(locale);
97
101
  const result: ChatAction[] = [];
98
102
  const copyText = copyTextFromMessage(entry.message);
99
103
 
100
104
  if (copyText) {
101
- result.push({ id: "copy", label: "Copy", icon: "ti ti-copy", copyText });
105
+ result.push({ id: "copy", label: t.actionCopy, icon: "ti ti-copy", copyText });
102
106
  }
103
107
  if (!entry.compactedAt && actions.onRetryMessage) {
104
108
  result.push(
105
109
  {
106
110
  id: "retry",
107
- label: "Try again",
111
+ label: t.actionTryAgain,
108
112
  icon: "ti ti-refresh",
109
113
  onSelect: () => actions.onRetryMessage?.(entry, { mode: "retry" }),
110
114
  },
111
115
  {
112
116
  id: "details",
113
- label: "More detailed",
117
+ label: t.actionMoreDetailed,
114
118
  icon: "ti ti-list-details",
115
119
  onSelect: () => actions.onRetryMessage?.(entry, { mode: "details" }),
116
120
  },
117
121
  {
118
122
  id: "concise",
119
- label: "More concise",
123
+ label: t.actionMoreConcise,
120
124
  icon: "ti ti-align-left",
121
125
  onSelect: () => actions.onRetryMessage?.(entry, { mode: "concise" }),
122
126
  },
123
127
  {
124
128
  id: "edit",
125
- label: "Edit prompt",
129
+ label: t.actionEditPrompt,
126
130
  icon: "ti ti-pencil",
127
- onSelect: () => openModifyRetryDialog(entry, actions.onRetryMessage!),
131
+ onSelect: () => openModifyRetryDialog(entry, actions.onRetryMessage!, locale),
128
132
  },
129
133
  );
130
134
  }
@@ -136,12 +140,13 @@ export const aiSteerMessageText = (block: Extract<AiTurnBlock, { kind: "steer_me
136
140
  export const createAiSteerMessageActions = (
137
141
  block: Extract<AiTurnBlock, { kind: "steer_message" }>,
138
142
  actions: AiChatActions,
143
+ locale: string,
139
144
  ): ChatAction[] => {
140
145
  if (block.status !== "failed" || !actions.onRetrySteer) return [];
141
146
  return [
142
147
  {
143
148
  id: "retry-steer",
144
- label: "Retry guidance",
149
+ label: aiChatMessages(locale).actionRetryGuidance,
145
150
  icon: "ti ti-refresh",
146
151
  onSelect: () => actions.onRetrySteer?.(block),
147
152
  },
@@ -228,7 +228,7 @@ export function CloudSurveyBlock(props: {
228
228
  </Show>
229
229
  </div>
230
230
  </div>
231
- <footer class="flex min-h-12 items-center gap-3 border-t border-[var(--k2b-border)] bg-[var(--k2b-surface-subtle)] px-4 py-2.5">
231
+ <footer class="flex min-h-12 items-center gap-3 px-4 pb-3">
232
232
  <Show when={submitted()}>
233
233
  <span class="inline-flex items-center gap-1.5 text-xs font-medium text-secondary">
234
234
  <i class="ti ti-check text-[var(--k2b-ai-accent)]" aria-hidden="true" />
@@ -4,6 +4,7 @@ import { getCapability } from "../_internal/registry";
4
4
  import { logger } from "../services/logging";
5
5
  import {
6
6
  createMandate,
7
+ MANDATE_POLICY_DENIED_MESSAGE,
7
8
  type Mandate,
8
9
  type MandatePolicyV1,
9
10
  MandatePolicyV1Schema,
@@ -88,7 +89,14 @@ export class AiChatTaskIdempotencyConflictError extends Error {
88
89
  }
89
90
 
90
91
  class AiChatTaskCreateRace extends Error {}
91
- class AiChatTaskAuthorityError extends Error {}
92
+ export class AiChatTaskAuthorityError extends Error {
93
+ constructor(
94
+ message: string,
95
+ readonly code?: string,
96
+ ) {
97
+ super(message);
98
+ }
99
+ }
92
100
  const log = logger("ai:chat-tasks");
93
101
 
94
102
  type OccurrenceRow = {
@@ -497,10 +505,15 @@ export const aiChatTasks = {
497
505
  input: input.input,
498
506
  capabilityApproval: input.approval,
499
507
  });
500
- if (!result.ok)
508
+ if (!result.ok) {
509
+ const policyDenied = result.error.code === "FORBIDDEN" && result.error.message === MANDATE_POLICY_DENIED_MESSAGE;
501
510
  throw new AiChatTaskAuthorityError(
502
- `Background task cannot call ${input.appId}.${input.capabilityId}: ${result.error.message}. Check the task grants and fixed inputs in the normal chat; this run cannot expand them.`,
511
+ policyDenied
512
+ ? `This task's grants do not allow ${input.appId}.${input.capabilityId} with these inputs; continue with the granted tools or explain what is missing.`
513
+ : `Background task cannot call ${input.appId}.${input.capabilityId}: ${result.error.message}. Check the task grants and fixed inputs in the normal chat; this run cannot expand them.`,
514
+ policyDenied ? "MANDATE_POLICY_DENIED" : result.error.code,
503
515
  );
516
+ }
504
517
  },
505
518
  list: async (input: {
506
519
  userId: string;
@@ -94,13 +94,26 @@ const isCurrentStreamSession = (current: AiStreamSession | null, candidate: AiSt
94
94
 
95
95
  const DEFAULT_RUN_ERROR = "Assistant response failed.";
96
96
 
97
- const conversationRunError = (conversation: AiConversation | null | undefined): string | null =>
98
- conversation?.runStatus === "failed" ? conversation.runError?.trim() || DEFAULT_RUN_ERROR : null;
97
+ /** The failure of the chat's latest turn, and that turn when known. */
98
+ type AiRunError = { message: string; turnId: string | null };
99
99
 
100
- const runErrorFromEvent = (event: AiStreamEvent, activeTurnId: string | null | undefined): string | null | undefined => {
100
+ const conversationRunError = (conversation: AiConversation | null | undefined): AiRunError | null =>
101
+ conversation?.runStatus === "failed"
102
+ ? { message: conversation.runError?.trim() || DEFAULT_RUN_ERROR, turnId: conversation.runTurnId ?? null }
103
+ : null;
104
+
105
+ /**
106
+ * Whether the failed turn shows its failure itself: its last message carries the reason, and the timeline words it in
107
+ * a notice at the turn's end. The run error then stays out of `error()`, so it does not appear twice. A turn without a
108
+ * recorded reason, such as a failed compaction or a turn from an earlier release, still reports it there.
109
+ */
110
+ const turnShowsError = (messages: readonly AiStoredMessage[], turnId: string | null): boolean =>
111
+ turnId !== null && messages.some((message) => message.loopId === turnId && message.meta?.turnError);
112
+
113
+ const runErrorFromEvent = (event: AiStreamEvent, activeTurnId: string | null | undefined): AiRunError | null | undefined => {
101
114
  if (event.type === "state") return conversationRunError(event.conversation);
102
115
  if (event.type !== "turn_finished" || event.turnId !== activeTurnId) return undefined;
103
- return event.status === "failed" ? event.error?.trim() || DEFAULT_RUN_ERROR : null;
116
+ return event.status === "failed" ? { message: event.error?.trim() || DEFAULT_RUN_ERROR, turnId: event.turnId } : null;
104
117
  };
105
118
 
106
119
  export type CreateAiChatControllerOptions = {
@@ -168,7 +181,7 @@ const errorText = (error: unknown, fallback: string): string =>
168
181
  export const createAiChatController = (options: CreateAiChatControllerOptions) => {
169
182
  const [activeConversationId, setActiveConversationIdSignal] = createSignal<string | null>(options.initialConversationId ?? null);
170
183
  const [globalError, setGlobalError] = createSignal<string | null>(options.initialError ?? null);
171
- const [runError, setRunError] = createSignal<string | null>(conversationRunError(options.initialDetail?.conversation));
184
+ const [runError, setRunError] = createSignal<AiRunError | null>(conversationRunError(options.initialDetail?.conversation));
172
185
  const [runStatusRaw, setRunStatusRaw] = createSignal<AiChatRunStatus | null>(null);
173
186
  const [streamStatus, setStreamStatus] = createSignal<AiStreamStatus>("idle");
174
187
  const initialProjection = options.initialDetail ? detailToProjection(options.initialDetail) : emptyProjection();
@@ -250,7 +263,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
250
263
  const conversationId = activeConversationId();
251
264
  if (conversationId) openStream(conversationId);
252
265
  };
253
- const error = () => globalError() ?? runError();
266
+ const error = () => {
267
+ const run = runError();
268
+ return globalError() ?? (run && !turnShowsError(state.messages, run.turnId) ? run.message : null);
269
+ };
254
270
 
255
271
  const activeTurn = () => state.activeTurn;
256
272
  const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
@@ -1415,6 +1431,7 @@ export const __aiControllerTest = {
1415
1431
  completeFrontendToolBlock,
1416
1432
  conversationRunError,
1417
1433
  failSteerBlock,
1434
+ turnShowsError,
1418
1435
  projectionForConversationOpen,
1419
1436
  reconcileSteerBlocks,
1420
1437
  runErrorFromEvent,