@k2b/cloud 0.26.0 → 0.28.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 (121) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/define-app.ts +8 -1
  3. package/src/_internal/process-identity.ts +7 -1
  4. package/src/_internal/registry-validation.ts +3 -0
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/ai/admin.ts +1 -0
  7. package/src/ai/browser-code-contracts.ts +46 -64
  8. package/src/ai/browser.ts +9 -1
  9. package/src/ai/capabilities.ts +82 -22
  10. package/src/ai/chat/blocks.tsx +16 -7
  11. package/src/ai/chat/builtin-tools.tsx +31 -23
  12. package/src/ai/chat/file-tools.tsx +4 -1
  13. package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
  14. package/src/ai/chat/message-actions.tsx +123 -101
  15. package/src/ai/chat/message-utils.ts +30 -1
  16. package/src/ai/chat/messages.ts +206 -2
  17. package/src/ai/chat/presentation.tsx +94 -28
  18. package/src/ai/chat/tool-groups.ts +1 -1
  19. package/src/ai/chat/turn-error.ts +21 -0
  20. package/src/ai/chat/turn-view.tsx +102 -16
  21. package/src/ai/chat/user-message.tsx +19 -14
  22. package/src/ai/chat/visual-tools.tsx +1 -1
  23. package/src/ai/client/controller.ts +83 -37
  24. package/src/ai/client/file-source.ts +20 -3
  25. package/src/ai/client/projection.ts +7 -2
  26. package/src/ai/code-mode-skill.ts +32 -36
  27. package/src/ai/code-runtime-tools.ts +14 -1
  28. package/src/ai/code-source-contracts.ts +56 -6
  29. package/src/ai/code-source-tools.ts +10 -3
  30. package/src/ai/credentials.ts +17 -3
  31. package/src/ai/data-analysis-skill.ts +3 -3
  32. package/src/ai/default-tools.ts +15 -16
  33. package/src/ai/executor.ts +281 -141
  34. package/src/ai/file-context.ts +14 -2
  35. package/src/ai/file-tools.ts +17 -3
  36. package/src/ai/files-store.ts +134 -11
  37. package/src/ai/grids-skill.ts +2 -2
  38. package/src/ai/index.ts +9 -0
  39. package/src/ai/memories.ts +14 -0
  40. package/src/ai/migrate.ts +125 -0
  41. package/src/ai/model-request-settings.ts +98 -0
  42. package/src/ai/open-tool-calls.ts +87 -0
  43. package/src/ai/protocol.ts +6 -0
  44. package/src/ai/provider.ts +7 -1
  45. package/src/ai/quota-provider.ts +2 -2
  46. package/src/ai/request-headers.ts +117 -0
  47. package/src/ai/routes.ts +40 -6
  48. package/src/ai/run-timeout.ts +4 -5
  49. package/src/ai/runtime.ts +9 -1
  50. package/src/ai/settings.ts +19 -2
  51. package/src/ai/skill-seeds.ts +35 -7
  52. package/src/ai/skills.ts +26 -0
  53. package/src/ai/solid.ts +1 -1
  54. package/src/ai/store.ts +155 -71
  55. package/src/ai/stream.ts +180 -37
  56. package/src/ai/structured.ts +20 -5
  57. package/src/ai/system-prompt.ts +8 -0
  58. package/src/ai/tool-call-names.ts +45 -0
  59. package/src/ai/turn-failure.ts +100 -0
  60. package/src/ai/turn-policy.ts +248 -0
  61. package/src/ai/types.ts +52 -4
  62. package/src/api/admin-ai-quotas.ts +36 -1
  63. package/src/api/admin-core-settings.ts +16 -23
  64. package/src/api/admin-outgoing-mail.ts +242 -0
  65. package/src/api/index.ts +2 -0
  66. package/src/browser/FileChooser.tsx +11 -0
  67. package/src/browser/file-chooser-messages.ts +2 -0
  68. package/src/cli/admin/ai-quotas.ts +70 -1
  69. package/src/cli/admin/index.ts +8 -0
  70. package/src/cli/admin/notifications.ts +6 -0
  71. package/src/cli/admin/outgoing-mail.ts +215 -0
  72. package/src/contracts/app.ts +2 -0
  73. package/src/contracts/index.ts +1 -0
  74. package/src/contracts/outgoing-mail.ts +265 -0
  75. package/src/contracts/registry.ts +2 -0
  76. package/src/services/help/store.ts +2 -1
  77. package/src/services/index.ts +13 -0
  78. package/src/services/notifications/batches.ts +139 -79
  79. package/src/services/notifications/channels.ts +33 -13
  80. package/src/services/notifications/dispatcher.ts +57 -5
  81. package/src/services/notifications/email-frame.fixture.html +51 -0
  82. package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
  83. package/src/services/notifications/email-mail.ts +101 -0
  84. package/src/services/notifications/index.ts +49 -36
  85. package/src/services/notifications/observability.ts +9 -1
  86. package/src/services/notifications/platform.ts +1 -1
  87. package/src/services/notifications/runtime.ts +9 -3
  88. package/src/services/outgoing-mail/admin.ts +84 -0
  89. package/src/services/outgoing-mail/attachments.ts +135 -0
  90. package/src/services/outgoing-mail/bulk.ts +11 -0
  91. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  92. package/src/services/outgoing-mail/drain.ts +60 -0
  93. package/src/services/outgoing-mail/enqueue.ts +180 -0
  94. package/src/services/outgoing-mail/index.ts +112 -0
  95. package/src/services/outgoing-mail/message.ts +14 -0
  96. package/src/services/outgoing-mail/messages.ts +431 -0
  97. package/src/services/outgoing-mail/retention.ts +48 -0
  98. package/src/services/outgoing-mail/runtime.ts +72 -0
  99. package/src/services/outgoing-mail/send.ts +130 -0
  100. package/src/services/outgoing-mail/store.ts +397 -0
  101. package/src/services/outgoing-mail/sync.ts +39 -0
  102. package/src/services/outgoing-mail/test-send.ts +40 -0
  103. package/src/services/outgoing-mail/transport.ts +17 -0
  104. package/src/services/pdf/markdown.ts +22 -4
  105. package/src/services/postgres.ts +15 -0
  106. package/src/services/settings/core-settings.ts +19 -38
  107. package/src/services/settings/store.ts +5 -1
  108. package/src/shared/ai-model-request-settings.ts +21 -0
  109. package/src/shared/ai-platform-prompt.ts +49 -5
  110. package/src/shared/ai-request-options.ts +185 -0
  111. package/src/shared/markdown/extensions/links.ts +28 -20
  112. package/src/shared/markdown/index.ts +14 -5
  113. package/src/shared/markdown/shared.ts +0 -7
  114. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  115. package/src/ssr/admin-navigation.ts +1 -1
  116. package/src/ssr/platform-messages.ts +3 -1
  117. package/src/ssr/workspace-navigation.ts +7 -1
  118. package/src/styles/effects.css +15 -17
  119. package/src/styles/tokens.css +2 -0
  120. package/src/styles/utilities-markdown-editor.css +4 -39
  121. package/src/styles/utilities-markdown-table.css +14 -17
@@ -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,
@@ -15,7 +15,8 @@ import {
15
15
  untrack,
16
16
  } from "solid-js";
17
17
  import { markdown } from "../../shared";
18
- import type { AiTurnBlock } from "../protocol";
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
 
@@ -47,8 +49,16 @@ export type AiTurnSegment = {
47
49
  duration: () => AiTurnDuration | null;
48
50
  /** Shown above the work of a delivered scheduled result. */
49
51
  scheduledTask?: { taskId: string; occurrenceId: string } | null;
50
- /** A model call of the live turn waits for its retry. Set on the turn's last segment, so the wait ends the turn. */
51
- retrying?: boolean;
52
+ /**
53
+ * The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
54
+ * reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
55
+ * it in a row at the end of its last segment.
56
+ */
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;
52
62
  };
53
63
 
54
64
  const isLive = (phase: AiTurnPhase) => phase === "running" || phase === "waiting";
@@ -63,6 +73,10 @@ export const formatWorkDuration = (ms: number, t: Messages, long = false): strin
63
73
  return long ? t.durationHoursLong({ hours, minutes: minutes % 60 }) : t.durationHours({ hours, minutes: minutes % 60 });
64
74
  };
65
75
 
76
+ /** Identity of the current step: it changes when another block becomes current or the current call changes state. */
77
+ const stepKey = (block: AiTurnBlock | null): string =>
78
+ !block ? "start" : `${block.id}:${block.kind === "tool" || block.kind === "compaction" ? block.status : block.kind}`;
79
+
66
80
  /** A running clock: "2:41", "1:02:41". */
67
81
  export const formatWorkClock = (ms: number): string => {
68
82
  const total = Math.max(0, Math.floor(ms / 1000));
@@ -80,14 +94,25 @@ const toolTarget = (block: ToolBlock): string => {
80
94
  return typeof value === "string" ? basename(value.trim()) : "";
81
95
  };
82
96
 
83
- /** The current step of a live turn, as a short progressive phrase. */
84
- export const liveStepLabel = (block: AiTurnBlock | null, t: Messages): string => {
97
+ /** A step that runs longer than this shows its duration, so a long step does not look like a stalled one. */
98
+ export const AI_LONG_STEP_MS = AI_TURN_LEASE_MS;
99
+
100
+ /**
101
+ * The current step of a live turn, as a short progressive phrase. A step that runs long shows its duration in place of
102
+ * its target: "Running code · 3 min".
103
+ */
104
+ export const liveStepLabel = (block: AiTurnBlock | null, t: Messages, stepMs = 0): string => {
105
+ if (stepMs < AI_LONG_STEP_MS) return stepPhrase(block, t, true);
106
+ return `${stepPhrase(block, t, false)} · ${formatWorkDuration(stepMs, t)}`;
107
+ };
108
+
109
+ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean): string => {
85
110
  if (!block || block.kind === "thinking" || block.kind === "steer_applied" || block.kind === "steer_message") return t.stepThinking;
86
111
  if (block.kind === "text") return t.stepWriting;
87
112
  if (block.kind === "compaction") return block.status === "running" ? t.stepCompacting : t.stepThinking;
88
113
  // A finished call means the model is deciding what to do next.
89
114
  if (block.status !== "running" && block.status !== "awaiting_client") return t.stepThinking;
90
- const target = toolTarget(block);
115
+ const target = withTargets ? toolTarget(block) : "";
91
116
  const withTarget = (label: string) => (target ? `${label} · ${target}` : label);
92
117
  const name = block.name;
93
118
  if (block.presentation?.kind === "capability") return withTarget(t.stepUsingApp({ app: block.presentation.appName }));
@@ -102,7 +127,7 @@ export const liveStepLabel = (block: AiTurnBlock | null, t: Messages): string =>
102
127
  if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
103
128
  return withTarget(t.stepWritingCode);
104
129
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
105
- if (["code_inspect", "code_interact"].includes(name)) return t.stepCheckingApp;
130
+ if (name === "code_inspect") return t.stepCheckingApp;
106
131
  if (name.startsWith("code_")) return t.stepCode;
107
132
  if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
108
133
  if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
@@ -117,20 +142,37 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
117
142
  const t = () => aiChatMessages(locale());
118
143
  const segment = () => props.segment();
119
144
  const layout = () => segment().layout;
120
- const waiting = () => segment().phase === "waiting" && layout().waitingFor !== null;
121
- const working = () => isLive(segment().phase) && !waiting();
145
+ const reconnecting = () => isLive(segment().phase) && segment().reconnect === "line";
146
+ const waiting = () => !reconnecting() && segment().phase === "waiting" && layout().waitingFor !== null;
147
+ const working = () => isLive(segment().phase) && !waiting() && !reconnecting();
148
+ // The current step is timed on the work clock, so it stands while the turn reconnects.
149
+ const currentStep = createMemo(() => (isLive(segment().phase) ? stepKey(layout().current) : null));
150
+ const step = createMemo(on(currentStep, (key) => (key === null ? null : (untrack(segment().duration)?.workedMs ?? 0))));
151
+ const stepMs = () => {
152
+ const startedAt = step();
153
+ const workedMs = segment().duration()?.workedMs;
154
+ return startedAt === null || workedMs === undefined ? 0 : workedMs - startedAt;
155
+ };
156
+ // Screen readers hear once that the turn reconnects; the line itself is not read while it changes.
157
+ createEffect(
158
+ on(reconnecting, (now, before) => {
159
+ if (now && !before) announce(t().reconnecting);
160
+ }),
161
+ );
122
162
  const worked = () => {
123
163
  const duration = segment().duration();
124
164
  return !segment().earlier && duration && duration.workedMs > 0 ? duration.workedMs : null;
125
165
  };
126
166
  const steps = () => t().steps({ count: layout().steps });
127
167
  const label = () => {
128
- if (working()) return liveStepLabel(layout().current, t());
168
+ if (reconnecting()) return t().reconnecting;
169
+ if (working()) return liveStepLabel(layout().current, t(), stepMs());
129
170
  if (waiting()) return layout().waitingFor === "approval" ? t().waitingForApproval : t().waitingForAnswer;
130
171
  const duration = worked();
131
172
  const short = duration === null ? "" : formatWorkDuration(duration, t());
132
173
  if (segment().phase === "stopped") return t().workedStopped({ duration: short });
133
- if (segment().phase === "failed") return t().workedInterrupted({ duration: short });
174
+ // A failed turn with a stored reason says it in its notice; the line then reads like any finished turn.
175
+ if (segment().phase === "failed" && !segment().error) return t().workedInterrupted({ duration: short });
134
176
  return short ? t().workedFor({ duration: short }) : t().worked;
135
177
  };
136
178
  const ariaLabel = () => {
@@ -140,12 +182,12 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
140
182
  return t().workedLabel({
141
183
  duration: duration === null ? "" : formatWorkDuration(duration, t(), true),
142
184
  steps: layout().steps ? steps() : "",
143
- ending: phase === "stopped" || phase === "failed" ? phase : null,
185
+ ending: phase === "stopped" || (phase === "failed" && !segment().error) ? phase : null,
144
186
  });
145
187
  };
146
188
  const trailing = () => {
147
189
  const duration = segment().duration();
148
- if (working() && duration) {
190
+ if ((working() || reconnecting()) && duration) {
149
191
  return (
150
192
  <span class="ai-turn-work__meta">
151
193
  {formatWorkClock(duration.workedMs)}
@@ -487,6 +529,37 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
487
529
  );
488
530
  }
489
531
 
532
+ /**
533
+ * Why a turn failed and what comes next, at its end. "Continue" sends a visible message through the host's normal
534
+ * send path, so the person sees what was asked; it is offered only for the chat's newest turn and only when a new
535
+ * turn can finish the work.
536
+ */
537
+ function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean }) {
538
+ const locale = useLocale();
539
+ const t = () => aiChatMessages(locale());
540
+ const actions = useAiChatActions();
541
+ const [continuing, setContinuing] = createSignal(false);
542
+ const canContinue = () => props.continuable && aiTurnErrorCanContinue(props.error) && Boolean(actions.onContinueTurn);
543
+ const continueTurn = async () => {
544
+ if (continuing()) return;
545
+ setContinuing(true);
546
+ try {
547
+ await actions.onContinueTurn?.(t().continueTurnMessage);
548
+ } finally {
549
+ setContinuing(false);
550
+ }
551
+ };
552
+ return (
553
+ <NoticeCard tone="danger" class="ai-turn__notice" title={t().turnErrorTitle} detail={aiTurnErrorDescription(props.error, locale())}>
554
+ <Show when={canContinue()}>
555
+ <Button variant="text" size="sm" loading={continuing()} disabled={actions.actionDisabled?.()} onClick={() => void continueTurn()}>
556
+ <i class="ti ti-player-play" aria-hidden="true" /> {t().continueTurn}
557
+ </Button>
558
+ </Show>
559
+ </NoticeCard>
560
+ );
561
+ }
562
+
490
563
  /**
491
564
  * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
492
565
  * is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
@@ -570,6 +643,16 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
570
643
  const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
571
644
  const actionIds = createMemo(() => [...actions().keys()]);
572
645
  const live = () => isLive(props.segment().phase);
646
+ // Screen readers hear once that the turn they followed failed and why; history read later is not announced.
647
+ createEffect(
648
+ on(live, (now, before) => {
649
+ const error = props.segment().error;
650
+ if (before && !now && error) {
651
+ const t = aiChatMessages(locale());
652
+ announce(`${t.turnErrorTitle} ${aiTurnErrorDescription(error, locale())}`);
653
+ }
654
+ }),
655
+ );
573
656
  return (
574
657
  <AiToolDisclosureProvider state={props.disclosureState}>
575
658
  <div class="ai-turn">
@@ -599,10 +682,13 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
599
682
  <For each={actionIds()}>
600
683
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
601
684
  </For>
602
- <Show when={props.segment().retrying}>
603
- {/* Calm by design: no busy sweep while the model call waits for its retry. */}
685
+ <Show when={props.segment().reconnect === "row"}>
686
+ {/* Calm by design: no busy sweep while the turn reconnects. */}
604
687
  <Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
605
688
  </Show>
689
+ <Show when={props.segment().error}>
690
+ {(error) => <AiTurnErrorNotice error={error()} continuable={props.segment().continuable === true} />}
691
+ </Show>
606
692
  </div>
607
693
  </AiToolDisclosureProvider>
608
694
  );
@@ -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" />
@@ -2,7 +2,7 @@ import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
2
2
  import { createStore, reconcile } from "solid-js/store";
3
3
  import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
4
4
  import { aiChatMessages } from "../chat/messages";
5
- import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
5
+ import { AI_TURN_LEASE_MS, type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
6
6
  import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
7
7
  import type {
8
8
  AiClientToolId,
@@ -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();
@@ -201,6 +214,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
201
214
  const abortRequests = new Map<string, Promise<boolean>>();
202
215
  let stream: AiStreamHandle | null = null;
203
216
  let streamSession: AiStreamSession | null = null;
217
+ let silenceTimer: ReturnType<typeof setTimeout> | undefined;
204
218
  /** The chat error left by a stream that ended for good, until a stream opens again. */
205
219
  let streamError: string | null = null;
206
220
  let streamGeneration = 0;
@@ -249,7 +263,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
249
263
  const conversationId = activeConversationId();
250
264
  if (conversationId) openStream(conversationId);
251
265
  };
252
- const error = () => globalError() ?? runError();
266
+ const error = () => {
267
+ const run = runError();
268
+ return globalError() ?? (run && !turnShowsError(state.messages, run.turnId) ? run.message : null);
269
+ };
253
270
 
254
271
  const activeTurn = () => state.activeTurn;
255
272
  const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
@@ -294,8 +311,32 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
294
311
  void markConversationViewed(session.conversationId);
295
312
  };
296
313
 
314
+ const clearSilenceTimer = () => {
315
+ if (silenceTimer) clearTimeout(silenceTimer);
316
+ silenceTimer = undefined;
317
+ };
318
+
319
+ /**
320
+ * A running turn whose open stream delivers nothing for one worker lease either works silently or lost an update,
321
+ * such as its end. The stream then starts again from the turn's saved state. The wait starts with the stream's
322
+ * first event, so a slow first state is never cut off. A turn that waits for an action in this browser is not
323
+ * silent, and a stream that reconnects brings a fresh state anyway.
324
+ */
325
+ const watchSilence = (session: AiStreamSession) => {
326
+ clearSilenceTimer();
327
+ silenceTimer = setTimeout(() => {
328
+ silenceTimer = undefined;
329
+ if (!isCurrentStreamSession(streamSession, session)) return;
330
+ const status = streamStatus();
331
+ if (state.activeTurn?.status !== "running" || status === "connecting" || status === "reconnecting") return watchSilence(session);
332
+ closeStream();
333
+ openStream(session.conversationId);
334
+ }, AI_TURN_LEASE_MS);
335
+ };
336
+
297
337
  const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
298
338
  if (!isCurrentStreamSession(streamSession, session)) return;
339
+ watchSilence(session);
299
340
  const conversationId = session.conversationId;
300
341
  if (event.type === "turn_finished" && pendingSends()[conversationId]) {
301
342
  completedDuringSend.set(conversationId, event.turnId);
@@ -327,6 +368,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
327
368
  };
328
369
 
329
370
  const closeStream = () => {
371
+ clearSilenceTimer();
330
372
  stream?.close();
331
373
  stream = null;
332
374
  streamSession = null;
@@ -400,15 +442,15 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
400
442
  console.warn(`No frontend handler registered for AI tool "${name}" — leaving the action request pending.`);
401
443
  return false;
402
444
  }
445
+ let result: unknown;
403
446
  try {
404
- const result = await handler({ name, callId, args, turnId, conversationId });
405
- return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
447
+ result = await handler({ name, callId, args, turnId, conversationId });
406
448
  } catch (toolError) {
407
- return submitTurnActionForConversation(conversationId, turnId, callId, {
408
- type: "tool_result",
409
- result: { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" },
410
- });
449
+ result = { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" };
411
450
  }
451
+ const submitted = await submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
452
+ if (submitted) showActionResolved(conversationId, turnId, { type: "tool_result", callId, result });
453
+ return submitted;
412
454
  };
413
455
 
414
456
  // ---- conversation loading --------------------------------------------
@@ -1258,6 +1300,28 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1258
1300
  }
1259
1301
  };
1260
1302
 
1303
+ /**
1304
+ * Shows an action the server accepted as resolved, so the turn runs on before the stream confirms it. A turn that
1305
+ * runs is watched for silence again, which heals a confirmation that never arrives.
1306
+ */
1307
+ const showActionResolved = (
1308
+ conversationId: string,
1309
+ turnId: string,
1310
+ action: { type: "approval_response"; callId: string; approved: boolean } | { type: "tool_result"; callId: string; result: unknown },
1311
+ ) => {
1312
+ if (!isActiveConversation(conversationId) || state.activeTurn?.turnId !== turnId) return;
1313
+ const waitingStatus = action.type === "approval_response" ? "awaiting_approval" : "awaiting_client";
1314
+ setState("activeTurn", (current) => {
1315
+ if (!current || current.turnId !== turnId) return current;
1316
+ const pending = current.blocks.some(
1317
+ (block) => block.kind === "tool" && block.callId === action.callId && block.status === waitingStatus,
1318
+ );
1319
+ if (!pending) return current;
1320
+ return reconcileActiveTurnActions(current, [{ callId: action.callId, resolvedEvent: action }]);
1321
+ });
1322
+ cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1323
+ };
1324
+
1261
1325
  const submitTurnAction = (
1262
1326
  turnId: string,
1263
1327
  callId: string,
@@ -1277,18 +1341,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1277
1341
  approved: input.approved,
1278
1342
  remember: input.remember,
1279
1343
  });
1280
- if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
1281
- const action = { type: "approval_response" as const, callId: request.callId, approved: input.approved };
1282
- setState("activeTurn", (current) => {
1283
- if (!current || current.turnId !== request.turnId) return current;
1284
- const pending = current.blocks.some(
1285
- (block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_approval",
1286
- );
1287
- if (!pending) return current;
1288
- return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
1289
- });
1290
- cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1291
- return true;
1344
+ if (submitted)
1345
+ showActionResolved(conversationId, request.turnId, { type: "approval_response", callId: request.callId, approved: input.approved });
1346
+ return submitted;
1292
1347
  };
1293
1348
 
1294
1349
  const submitFrontendToolResult = async (request: { turnId: string; callId: string }, result: unknown) => {
@@ -1299,18 +1354,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1299
1354
  type: "tool_result",
1300
1355
  result,
1301
1356
  });
1302
- if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
1303
- const action = { type: "tool_result" as const, callId: request.callId, result };
1304
- setState("activeTurn", (current) => {
1305
- if (!current || current.turnId !== request.turnId) return current;
1306
- const pending = current.blocks.some(
1307
- (block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_client",
1308
- );
1309
- if (!pending) return current;
1310
- return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
1311
- });
1312
- cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
1313
- return true;
1357
+ if (submitted) showActionResolved(conversationId, request.turnId, { type: "tool_result", callId: request.callId, result });
1358
+ return submitted;
1314
1359
  };
1315
1360
 
1316
1361
  if (options.initialConversationId) {
@@ -1386,6 +1431,7 @@ export const __aiControllerTest = {
1386
1431
  completeFrontendToolBlock,
1387
1432
  conversationRunError,
1388
1433
  failSteerBlock,
1434
+ turnShowsError,
1389
1435
  projectionForConversationOpen,
1390
1436
  reconcileSteerBlocks,
1391
1437
  runErrorFromEvent,
@@ -30,7 +30,14 @@ const bytesToBase64 = (bytes: Uint8Array): string => {
30
30
  return btoa(binary);
31
31
  };
32
32
 
33
- export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource & { listFiles(): Promise<AiFileStat[]> } => {
33
+ export type ConversationFileSource = FileSource & {
34
+ /** Files below `prefix` (default `/`); with `page`, at most `page.limit` files by path after `page.after`. */
35
+ listFiles(prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]>;
36
+ /** Delete a folder with everything below it. */
37
+ removeFolder(path: string): Promise<void>;
38
+ };
39
+
40
+ export const conversationFileSource = (baseUrl: string, conversationId: string): ConversationFileSource => {
34
41
  const filesUrl = (suffix = "", params?: Record<string, string>) => {
35
42
  const query = params ? `?${new URLSearchParams(params).toString()}` : "";
36
43
  return `${baseUrl}/conversations/${conversationId}/files${suffix}${query}`;
@@ -45,8 +52,14 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
45
52
  return (await response.json()) as T;
46
53
  };
47
54
 
48
- const listFiles = async (): Promise<AiFileStat[]> =>
49
- (await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files")).files;
55
+ const listFiles = async (prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]> => {
56
+ const params: Record<string, string> = {
57
+ ...(prefix ? { prefix } : {}),
58
+ ...(page ? { limit: String(page.limit), ...(page.after ? { after: page.after } : {}) } : {}),
59
+ };
60
+ const url = Object.keys(params).length ? filesUrl("", params) : filesUrl();
61
+ return (await request<{ files: AiFileStat[] }>(url, { method: "GET" }, "Failed to load files")).files;
62
+ };
50
63
 
51
64
  return {
52
65
  previewPreferencesKey: "assistant.csv-preview",
@@ -80,6 +93,10 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
80
93
  await request(filesUrl("", { path }), { method: "DELETE" }, "Failed to delete file");
81
94
  },
82
95
 
96
+ async removeFolder(path) {
97
+ await request(filesUrl("", { path, recursive: "true" }), { method: "DELETE" }, "Failed to delete folder");
98
+ },
99
+
83
100
  async rename(from, to) {
84
101
  await request(filesUrl("/rename"), { method: "POST", body: JSON.stringify({ from, to }) }, "Failed to rename file");
85
102
  },
@@ -9,7 +9,10 @@ export type AiActiveTurn = {
9
9
  status: "running" | "waiting_for_action";
10
10
  blocks: AiTurnBlock[];
11
11
  modelProfileId: string | null;
12
- /** A model call failed transiently and waits for its retry. Live only: the next turn event or a snapshot ends it. */
12
+ /**
13
+ * A model call failed transiently and waits for its retry. Live only: the next turn event ends it, and so does a
14
+ * snapshot that has moved past the retry.
15
+ */
13
16
  providerRetry?: boolean;
14
17
  /** Server start of the turn, known from a state snapshot. */
15
18
  createdAt?: string;
@@ -98,7 +101,9 @@ export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiv
98
101
  const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
99
102
  const known = new Set(incoming.blocks.map((block) => block.id));
100
103
  const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
101
- return { ...turnTiming(previous), ...incoming, blocks, status: deriveStatus(blocks) };
104
+ // A snapshot never carries a provider retry. One at the retry's own position has seen nothing after it, so the wait goes on.
105
+ const retry = previous.providerRetry && incoming.seq === previous.seq ? { providerRetry: true } : {};
106
+ return { ...turnTiming(previous), ...incoming, ...retry, blocks, status: deriveStatus(blocks) };
102
107
  };
103
108
 
104
109
  export const reconcileActiveTurnActions = (