@k2b/cloud 0.27.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 (79) hide show
  1. package/package.json +2 -2
  2. package/src/ai/browser-code-contracts.ts +33 -63
  3. package/src/ai/browser.ts +1 -0
  4. package/src/ai/chat/blocks.tsx +16 -7
  5. package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
  6. package/src/ai/chat/message-actions.tsx +123 -101
  7. package/src/ai/chat/message-utils.ts +30 -1
  8. package/src/ai/chat/messages.ts +198 -0
  9. package/src/ai/chat/presentation.tsx +66 -20
  10. package/src/ai/chat/tool-groups.ts +1 -1
  11. package/src/ai/chat/turn-error.ts +21 -0
  12. package/src/ai/chat/turn-view.tsx +55 -4
  13. package/src/ai/chat/user-message.tsx +19 -14
  14. package/src/ai/chat/visual-tools.tsx +1 -1
  15. package/src/ai/client/controller.ts +23 -6
  16. package/src/ai/code-mode-skill.ts +21 -25
  17. package/src/ai/code-runtime-tools.ts +5 -1
  18. package/src/ai/code-source-contracts.ts +2 -2
  19. package/src/ai/data-analysis-skill.ts +3 -3
  20. package/src/ai/default-tools.ts +15 -16
  21. package/src/ai/executor.ts +135 -59
  22. package/src/ai/index.ts +2 -0
  23. package/src/ai/open-tool-calls.ts +87 -0
  24. package/src/ai/routes.ts +6 -0
  25. package/src/ai/run-timeout.ts +4 -5
  26. package/src/ai/runtime.ts +8 -0
  27. package/src/ai/skill-seeds.ts +4 -4
  28. package/src/ai/store.ts +46 -14
  29. package/src/ai/system-prompt.ts +4 -21
  30. package/src/ai/turn-failure.ts +100 -0
  31. package/src/ai/turn-policy.ts +2 -1
  32. package/src/ai/types.ts +28 -1
  33. package/src/api/admin-outgoing-mail.ts +185 -5
  34. package/src/browser/FileChooser.tsx +11 -0
  35. package/src/browser/file-chooser-messages.ts +2 -0
  36. package/src/cli/admin/index.ts +3 -1
  37. package/src/cli/admin/notifications.ts +6 -0
  38. package/src/cli/admin/outgoing-mail.ts +104 -7
  39. package/src/contracts/outgoing-mail.ts +189 -1
  40. package/src/services/help/store.ts +2 -1
  41. package/src/services/index.ts +11 -1
  42. package/src/services/notifications/batches.ts +139 -79
  43. package/src/services/notifications/channels.ts +33 -13
  44. package/src/services/notifications/dispatcher.ts +57 -5
  45. package/src/services/notifications/email-frame.fixture.html +51 -0
  46. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  47. package/src/services/notifications/email-mail.ts +101 -0
  48. package/src/services/notifications/index.ts +49 -36
  49. package/src/services/notifications/observability.ts +9 -1
  50. package/src/services/notifications/platform.ts +1 -1
  51. package/src/services/notifications/runtime.ts +9 -3
  52. package/src/services/outgoing-mail/admin.ts +84 -0
  53. package/src/services/outgoing-mail/attachments.ts +135 -0
  54. package/src/services/outgoing-mail/bulk.ts +11 -0
  55. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  56. package/src/services/outgoing-mail/drain.ts +60 -0
  57. package/src/services/outgoing-mail/enqueue.ts +180 -0
  58. package/src/services/outgoing-mail/index.ts +103 -10
  59. package/src/services/outgoing-mail/message.ts +14 -0
  60. package/src/services/outgoing-mail/messages.ts +431 -0
  61. package/src/services/outgoing-mail/retention.ts +48 -0
  62. package/src/services/outgoing-mail/runtime.ts +72 -0
  63. package/src/services/outgoing-mail/send.ts +130 -0
  64. package/src/services/outgoing-mail/store.ts +115 -4
  65. package/src/services/outgoing-mail/sync.ts +39 -0
  66. package/src/services/outgoing-mail/transport.ts +5 -1
  67. package/src/services/pdf/markdown.ts +22 -4
  68. package/src/services/postgres.ts +15 -0
  69. package/src/services/settings/core-settings.ts +18 -0
  70. package/src/shared/ai-platform-prompt.ts +48 -4
  71. package/src/shared/markdown/extensions/links.ts +28 -20
  72. package/src/shared/markdown/index.ts +14 -5
  73. package/src/shared/markdown/shared.ts +0 -7
  74. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  75. package/src/ssr/platform-messages.ts +1 -1
  76. package/src/styles/effects.css +15 -17
  77. package/src/styles/tokens.css +2 -0
  78. package/src/styles/utilities-markdown-editor.css +4 -39
  79. package/src/styles/utilities-markdown-table.css +14 -17
@@ -1,4 +1,5 @@
1
1
  import { i18n } from "@k2b/stdlib";
2
+ import type { AiTurnError } from "../types";
2
3
 
3
4
  const messages = i18n.define({
4
5
  baseLocale: "en",
@@ -21,6 +22,24 @@ const messages = i18n.define({
21
22
  thinking: "Thinking",
22
23
  showReasoning: "Show reasoning",
23
24
  reconnecting: "Reconnecting",
25
+ turnErrorTitle: "The answer was interrupted.",
26
+ turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
27
+ if (code === "model_unavailable") return "The model service did not answer.";
28
+ if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
29
+ if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
30
+ if (code === "time_limit")
31
+ return limitMinutes ? `The run time limit of ${limitMinutes} minutes was reached.` : "The run time limit was reached.";
32
+ if (code === "step_limit") return "The model reached its step limit for one answer.";
33
+ if (code === "wait_expired") return "The approval or answer did not arrive in time.";
34
+ if (code === "interrupted") return "The work was interrupted several times and could not be resumed.";
35
+ if (code === "not_allowed")
36
+ return "This chat may no longer use its model, project, or apps. Choose another model or ask your administrator.";
37
+ return "Something went wrong.";
38
+ },
39
+ turnErrorKept: "The results so far are kept.",
40
+ turnErrorContinueHint: "Send a new message to continue.",
41
+ continueTurn: "Continue",
42
+ continueTurnMessage: "Continue where you left off.",
24
43
  byteRange: ({ start, end }: { start: string; end: string }) => `Bytes ${start}–${end}`,
25
44
  worked: "Worked",
26
45
  workedFor: ({ duration }: { duration: string }) => `Worked ${duration}`,
@@ -187,6 +206,86 @@ const messages = i18n.define({
187
206
  calculate: "Calculate",
188
207
  inspectImage: ({ name }: { name: string }) => `Inspect image: ${name}`,
189
208
  readResource: ({ ref }: { ref: string }) => `Read resource: ${ref}`,
209
+ actionHelpful: "Helpful",
210
+ actionHelpfulRemove: "Remove positive feedback",
211
+ actionImprove: "Needs improvement",
212
+ actionImproveRemove: "Remove negative feedback",
213
+ actionMessageInfo: "Response details",
214
+ actionCopy: "Copy",
215
+ actionFork: "Continue in a new chat",
216
+ actionTryAgain: "Try again",
217
+ actionMoreDetailed: "More detailed",
218
+ actionMoreConcise: "More concise",
219
+ actionEditPrompt: "Edit and try again",
220
+ actionRetryGuidance: "Send guidance again",
221
+ cancel: "Cancel",
222
+ forkTitle: "Continue in a new chat",
223
+ forkConfirm: "Create chat",
224
+ forkName: "Chat name",
225
+ forkDefaultName: "Continued chat",
226
+ retryTitle: "Edit and try again",
227
+ retryPrompt: "Message",
228
+ retryPromptHint: "Attachments from the original message stay attached.",
229
+ retryFailed: "Could not try again. Your changes are still here.",
230
+ retrying: "Trying again",
231
+ feedbackTitle: "Help improve this response",
232
+ feedbackIntro: "What should be better? Choose all that apply or leave a short note.",
233
+ feedbackDetails: "Anything else",
234
+ feedbackDetailsHint: "Optional. Please leave out confidential information.",
235
+ feedbackSend: "Send feedback",
236
+ feedbackIncorrect: "Incorrect",
237
+ feedbackIncorrectHint: "Facts or conclusions were wrong.",
238
+ feedbackIgnoredRequest: "Did not do what I asked",
239
+ feedbackIgnoredRequestHint: "Important instructions were missed.",
240
+ feedbackIncomplete: "Incomplete",
241
+ feedbackIncompleteHint: "Important information or work was missing.",
242
+ feedbackWrongApp: "Used the wrong app or tool",
243
+ feedbackWrongAppHint: "Something was missing, unnecessary, or used badly.",
244
+ feedbackSlow: "Too slow",
245
+ feedbackSlowHint: "The answer took too long for the result.",
246
+ feedbackOther: "Something else",
247
+ feedbackOtherHint: "Something else should be better.",
248
+ infoSubtitle: "Technical details of this answer",
249
+ infoDetails: "Details",
250
+ infoProviderModel: "Provider model",
251
+ infoModelProfile: "Model",
252
+ infoLoopId: "Run ID",
253
+ infoFinished: "Ended",
254
+ infoEndCompleted: "Completed",
255
+ infoEndStopped: "Stopped",
256
+ infoEndFailed: "Failed",
257
+ infoEndInterrupted: "Interrupted",
258
+ infoEndStepLimit: "Step limit reached",
259
+ infoEndUsageLimit: "Usage limit reached",
260
+ infoEndContextFull: "Context window full",
261
+ infoEndLengthLimit: "Answer length limit reached",
262
+ infoCreated: "Created",
263
+ infoUnknown: "Unknown",
264
+ infoLegacy: "Older message",
265
+ infoTiming: "Time",
266
+ infoWorked: "Worked",
267
+ infoWorkedHint: "writing and tools",
268
+ infoGeneration: "Writing",
269
+ infoToolTime: "Tools",
270
+ infoWaiting: "Waiting for you",
271
+ infoWaitingHint: "approvals and answers",
272
+ infoWallClock: "Total time",
273
+ infoSpeed: "Writing speed",
274
+ infoTokensPerSecond: ({ value }: { value: string }) => `${value} tokens/s`,
275
+ infoUsage: "Usage",
276
+ infoInputTokens: "Input tokens",
277
+ infoOutputTokens: "Output tokens",
278
+ infoTotalTokens: "Total tokens",
279
+ infoTools: "Tools",
280
+ infoToolsHint: "Tools this answer used.",
281
+ infoIssueErrors: "errors",
282
+ infoIssueStream: "connection problems",
283
+ infoIssueMalformed: "malformed",
284
+ infoIssueCancelled: "cancelled",
285
+ compactedCount: ({ count, date }: { count: number; date: string }) =>
286
+ `${count === 1 ? "1 message" : `${count} messages`} summarized · ${date}`,
287
+ compactedHint: "From here on, the model sees this summary instead of the messages above.",
288
+ compactedEmpty: "No visible content",
190
289
  },
191
290
  de: {
192
291
  backgroundRun: "Hintergrundlauf",
@@ -207,6 +306,26 @@ const messages = i18n.define({
207
306
  thinking: "Denkt nach",
208
307
  showReasoning: "Denkprozess anzeigen",
209
308
  reconnecting: "Verbindung wird wiederhergestellt",
309
+ turnErrorTitle: "Die Antwort wurde abgebrochen.",
310
+ turnErrorReason: ({ code, limitMinutes }) => {
311
+ if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
312
+ if (code === "quota_exhausted")
313
+ return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
314
+ if (code === "context_full")
315
+ return "Dieser Chat ist zu lang für das Modell. Starte einen neuen Chat, um weiterzuarbeiten; dieser bleibt erhalten.";
316
+ if (code === "time_limit")
317
+ return limitMinutes ? `Das Laufzeitlimit von ${limitMinutes} Minuten ist erreicht.` : "Das Laufzeitlimit ist erreicht.";
318
+ if (code === "step_limit") return "Das Modell hat sein Schrittlimit für eine Antwort erreicht.";
319
+ if (code === "wait_expired") return "Die Freigabe oder Antwort kam nicht rechtzeitig.";
320
+ if (code === "interrupted") return "Die Arbeit wurde mehrmals unterbrochen und konnte nicht fortgesetzt werden.";
321
+ if (code === "not_allowed")
322
+ return "Dieser Chat darf sein Modell, sein Projekt oder seine Apps nicht mehr nutzen. Wähle ein anderes Modell oder wende dich an deine Administration.";
323
+ return "Etwas ist schiefgelaufen.";
324
+ },
325
+ turnErrorKept: "Die bisherigen Ergebnisse bleiben erhalten.",
326
+ turnErrorContinueHint: "Mit einer neuen Nachricht geht es weiter.",
327
+ continueTurn: "Weiterarbeiten",
328
+ continueTurnMessage: "Mach an der Stelle weiter, an der du aufgehört hast.",
210
329
  byteRange: ({ start, end }) => `Bytes ${start}–${end}`,
211
330
  worked: "Gearbeitet",
212
331
  workedFor: ({ duration }) => `${duration} gearbeitet`,
@@ -377,6 +496,85 @@ const messages = i18n.define({
377
496
  calculate: "Berechnen",
378
497
  inspectImage: ({ name }) => `Bild prüfen: ${name}`,
379
498
  readResource: ({ ref }) => `Ressource lesen: ${ref}`,
499
+ actionHelpful: "Hilfreich",
500
+ actionHelpfulRemove: "Positive Bewertung entfernen",
501
+ actionImprove: "Verbesserungswürdig",
502
+ actionImproveRemove: "Negative Bewertung entfernen",
503
+ actionMessageInfo: "Details zur Antwort",
504
+ actionCopy: "Kopieren",
505
+ actionFork: "In neuem Chat fortsetzen",
506
+ actionTryAgain: "Noch einmal versuchen",
507
+ actionMoreDetailed: "Ausführlicher",
508
+ actionMoreConcise: "Kürzer",
509
+ actionEditPrompt: "Bearbeiten und neu senden",
510
+ actionRetryGuidance: "Hinweis erneut senden",
511
+ cancel: "Abbrechen",
512
+ forkTitle: "In neuem Chat fortsetzen",
513
+ forkConfirm: "Chat erstellen",
514
+ forkName: "Name des Chats",
515
+ forkDefaultName: "Fortgesetzter Chat",
516
+ retryTitle: "Bearbeiten und neu senden",
517
+ retryPrompt: "Nachricht",
518
+ retryPromptHint: "Die Anhänge der ursprünglichen Nachricht bleiben erhalten.",
519
+ retryFailed: "Das hat nicht geklappt. Deine Änderungen sind noch da.",
520
+ retrying: "Wird gesendet",
521
+ feedbackTitle: "Hilf, diese Antwort zu verbessern",
522
+ feedbackIntro: "Was sollte besser sein? Wähle alles Passende aus oder schreib eine kurze Notiz.",
523
+ feedbackDetails: "Sonst noch etwas",
524
+ feedbackDetailsHint: "Optional. Bitte keine vertraulichen Daten angeben.",
525
+ feedbackSend: "Feedback senden",
526
+ feedbackIncorrect: "Falsch",
527
+ feedbackIncorrectHint: "Fakten oder Schlussfolgerungen stimmten nicht.",
528
+ feedbackIgnoredRequest: "Hat nicht getan, worum ich gebeten habe",
529
+ feedbackIgnoredRequestHint: "Wichtige Anweisungen wurden übersehen.",
530
+ feedbackIncomplete: "Unvollständig",
531
+ feedbackIncompleteHint: "Wichtige Informationen oder Arbeitsschritte fehlten.",
532
+ feedbackWrongApp: "Falsche App oder falsches Werkzeug",
533
+ feedbackWrongAppHint: "Etwas fehlte, war unnötig oder wurde falsch genutzt.",
534
+ feedbackSlow: "Zu langsam",
535
+ feedbackSlowHint: "Die Antwort hat für das Ergebnis zu lange gedauert.",
536
+ feedbackOther: "Etwas anderes",
537
+ feedbackOtherHint: "Etwas anderes sollte besser werden.",
538
+ infoSubtitle: "Technische Angaben zu dieser Antwort",
539
+ infoDetails: "Details",
540
+ infoProviderModel: "Modell beim Anbieter",
541
+ infoModelProfile: "Modell",
542
+ infoLoopId: "Durchlauf-ID",
543
+ infoFinished: "Beendet",
544
+ infoEndCompleted: "Abgeschlossen",
545
+ infoEndStopped: "Gestoppt",
546
+ infoEndFailed: "Fehlgeschlagen",
547
+ infoEndInterrupted: "Unterbrochen",
548
+ infoEndStepLimit: "Schrittlimit erreicht",
549
+ infoEndUsageLimit: "Nutzungslimit erreicht",
550
+ infoEndContextFull: "Kontextfenster voll",
551
+ infoEndLengthLimit: "Maximale Antwortlänge erreicht",
552
+ infoCreated: "Erstellt",
553
+ infoUnknown: "Unbekannt",
554
+ infoLegacy: "Ältere Nachricht",
555
+ infoTiming: "Zeit",
556
+ infoWorked: "Gearbeitet",
557
+ infoWorkedHint: "Schreiben und Werkzeuge",
558
+ infoGeneration: "Schreiben",
559
+ infoToolTime: "Werkzeuge",
560
+ infoWaiting: "Wartezeit auf dich",
561
+ infoWaitingHint: "Freigaben und Antworten",
562
+ infoWallClock: "Gesamtdauer",
563
+ infoSpeed: "Schreibtempo",
564
+ infoTokensPerSecond: ({ value }) => `${value} Token/s`,
565
+ infoUsage: "Verbrauch",
566
+ infoInputTokens: "Eingabe-Token",
567
+ infoOutputTokens: "Ausgabe-Token",
568
+ infoTotalTokens: "Token gesamt",
569
+ infoTools: "Werkzeuge",
570
+ infoToolsHint: "Werkzeuge, die diese Antwort genutzt hat.",
571
+ infoIssueErrors: "Fehler",
572
+ infoIssueStream: "Verbindungsprobleme",
573
+ infoIssueMalformed: "fehlerhaft",
574
+ infoIssueCancelled: "abgebrochen",
575
+ compactedCount: ({ count, date }) => `${count === 1 ? "1 Nachricht" : `${count} Nachrichten`} zusammengefasst · ${date}`,
576
+ compactedHint: "Ab hier sieht das Modell diese Zusammenfassung statt der Nachrichten darüber.",
577
+ compactedEmpty: "Kein sichtbarer Inhalt",
380
578
  },
381
579
  },
382
580
  });
@@ -15,9 +15,10 @@ import {
15
15
  import type { AiActiveTurn } from "../client/projection";
16
16
  import { type AiActiveTurnSegment, type AiTurnBlock, splitActiveTurnBlocks } from "../protocol";
17
17
  import { type AiAssistantTimelineItem, buildAiMessageTimeline } from "../timeline";
18
- import type { AiConversationTimelineEntry, AiStoredMessage } from "../types";
18
+ import type { AiConversationTimelineEntry, AiStoredMessage, AiTurnError } from "../types";
19
19
  import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
20
20
  import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
21
+ import { aiChatMessages } from "./messages";
21
22
  import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
22
23
  import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, waitsForUser } from "./turn-layout";
23
24
  import { TurnNavigator } from "./turn-navigator";
@@ -108,8 +109,9 @@ const storedPhase = (entries: readonly AiStoredMessage[]): AiTurnPhase => {
108
109
  return reason === "aborted" ? "stopped" : "failed";
109
110
  };
110
111
 
111
- /** Responses with a work line or rich places span the message column so their right edges align. */
112
- const isWideLayout = (layout: AiTurnLayout) => layout.showWork || layout.results.length > 0 || layout.actions.length > 0;
112
+ /** Responses with a work line, rich places, or a failure notice span the message column so their right edges align. */
113
+ const isWideLayout = (layout: AiTurnLayout, error?: AiTurnError) =>
114
+ layout.showWork || layout.results.length > 0 || layout.actions.length > 0 || Boolean(error);
113
115
 
114
116
  const isEmptyLayout = (layout: AiTurnLayout) => !isWideLayout(layout) && layout.text.length === 0;
115
117
 
@@ -162,13 +164,49 @@ const storedItems = (
162
164
  actions: AiChatActions,
163
165
  views: TurnViews,
164
166
  locale: string,
167
+ idle: boolean,
165
168
  ): ChatTimelineItem[] => {
166
169
  const timeline = buildAiMessageTimeline([...messages]);
167
170
  const lastItemOfLoop = new Map<string, number>();
171
+ // A failed turn's notice ends its last item, which is its user message when the model never answered.
172
+ const lastEntryOfLoop = new Map<string, number>();
168
173
  timeline.forEach((item, index) => {
169
- if (item.type === "assistant" && item.loopId) lastItemOfLoop.set(item.loopId, index);
174
+ const loopId = item.type === "summary" ? null : item.type === "user" ? item.entry.loopId : item.loopId;
175
+ if (!loopId) return;
176
+ lastEntryOfLoop.set(loopId, index);
177
+ if (item.type === "assistant") lastItemOfLoop.set(loopId, index);
170
178
  });
179
+ const turnErrors = new Map<string, AiTurnError>();
180
+ for (const message of messages) if (message.loopId && message.meta?.turnError) turnErrors.set(message.loopId, message.meta.turnError);
181
+ // Only the chat's newest turn can be continued, and only while no other turn runs.
182
+ const newestLoop = idle ? (messages.at(-1)?.loopId ?? null) : null;
171
183
  const segmentId = createSegmentIds();
184
+ // The model never answered: the notice stands where the live turn showed its progress, under the same id.
185
+ const unansweredNotice = (entry: AiStoredMessage, itemIndex: number): ChatTimelineItem[] => {
186
+ const loopId = entry.loopId;
187
+ const error = loopId && lastEntryOfLoop.get(loopId) === itemIndex ? turnErrors.get(loopId) : undefined;
188
+ if (!loopId || !error) return [];
189
+ const id = segmentId(loopId, "start");
190
+ return [
191
+ {
192
+ kind: "message",
193
+ id,
194
+ role: "assistant",
195
+ createdAt: entry.createdAt,
196
+ class: "ai-chat-message-wide",
197
+ content: views.content({
198
+ id,
199
+ turnId: loopId,
200
+ phase: "failed",
201
+ layout: layoutAiTurn([], { phase: "failed" }),
202
+ earlier: false,
203
+ duration: () => null,
204
+ error,
205
+ continuable: newestLoop === loopId,
206
+ }),
207
+ },
208
+ ];
209
+ };
172
210
  return timeline.flatMap((item, itemIndex): ChatTimelineItem | ChatTimelineItem[] => {
173
211
  if (item.type === "user") {
174
212
  const text = aiUserMessageText(item.entry);
@@ -176,7 +214,7 @@ const storedItems = (
176
214
  if (agentMessage) {
177
215
  const separator = text.indexOf("\n\n");
178
216
  const forwardedText = separator >= 0 ? text.slice(separator + 2) : text;
179
- return {
217
+ const systemItem: ChatTimelineItem = {
180
218
  kind: "message",
181
219
  id: item.id,
182
220
  role: "system",
@@ -208,18 +246,20 @@ const storedItems = (
208
246
  ),
209
247
  anchorId: item.entry.seq,
210
248
  };
249
+ return [systemItem, ...unansweredNotice(item.entry, itemIndex)];
211
250
  }
212
- return {
251
+ const userItem: ChatTimelineItem = {
213
252
  kind: "message",
214
253
  id: item.id,
215
254
  role: "user",
216
255
  createdAt: item.entry.createdAt,
217
256
  content: text ? <p class="whitespace-pre-wrap">{text}</p> : undefined,
218
257
  attachments: aiUserMessageAttachments(item.entry, actions),
219
- actions: createAiUserMessageActions(item.entry, actions),
258
+ actions: createAiUserMessageActions(item.entry, actions, locale),
220
259
  actionDisplay: "menu",
221
260
  anchorId: item.entry.seq,
222
261
  };
262
+ return [userItem, ...unansweredNotice(item.entry, itemIndex)];
223
263
  }
224
264
 
225
265
  if (item.type === "summary") {
@@ -228,16 +268,14 @@ const storedItems = (
228
268
  return {
229
269
  kind: "activity",
230
270
  id: item.id,
231
- label: "Context compacted",
232
- description: count ? `${count} message${count === 1 ? "" : "s"} summarized · ${date}` : date,
271
+ label: aiChatMessages(locale).compacted,
272
+ description: count ? aiChatMessages(locale).compactedCount({ count, date }) : date,
233
273
  icon: "ti ti-brain",
234
274
  tone: "ai",
235
275
  content: (
236
276
  <div>
237
- <p class="mb-1 text-[10px] font-medium uppercase tracking-wide text-dimmed">
238
- The model now sees this summary instead of the messages above
239
- </p>
240
- <p class="whitespace-pre-wrap">{textFromMessage(item.entry.message) || "No visible content"}</p>
277
+ <p class="mb-1 text-dimmed">{aiChatMessages(locale).compactedHint}</p>
278
+ <p class="whitespace-pre-wrap">{textFromMessage(item.entry.message) || aiChatMessages(locale).compactedEmpty}</p>
241
279
  </div>
242
280
  ),
243
281
  };
@@ -245,9 +283,12 @@ const storedItems = (
245
283
 
246
284
  const actionEntry = item.actionEntry?.compactedAt ? null : item.actionEntry;
247
285
  const lastOfLoop = !item.loopId || lastItemOfLoop.get(item.loopId) === itemIndex;
248
- const phase = lastOfLoop ? storedPhase(item.entries) : "completed";
286
+ const turnError = item.loopId && lastOfLoop ? turnErrors.get(item.loopId) : undefined;
287
+ const phase = turnError ? "failed" : lastOfLoop ? storedPhase(item.entries) : "completed";
249
288
  const segments = splitSurveyResults(item.blocks);
250
- if (segments.length === 0) segments.push({ type: "assistant", blocks: [] });
289
+ // A turn that failed after an accepted answer ends with its notice where the live turn showed its progress, below
290
+ // the answer and under the same id, as activeItems() places it.
291
+ if (segments.length === 0 || (turnError && segments.at(-1)?.type === "survey")) segments.push({ type: "assistant", blocks: [] });
251
292
  const lastAssistant = segments.findLastIndex((segment) => segment.type === "assistant");
252
293
  const scheduledTask = item.entries.find((entry) => entry.meta?.scheduledTask)?.meta?.scheduledTask ?? null;
253
294
  let firstSegment = true;
@@ -268,7 +309,8 @@ const storedItems = (
268
309
  : undefined;
269
310
  const task = firstSegment ? scheduledTask : null;
270
311
  firstSegment = false;
271
- if (isEmptyLayout(layout) && !messageActions && !task) return [];
312
+ const error = last ? turnError : undefined;
313
+ if (isEmptyLayout(layout) && !messageActions && !task && !error) return [];
272
314
  const id = item.loopId ? segmentId(item.loopId, segmentOpener) : `${item.id}:${index}`;
273
315
  return [
274
316
  {
@@ -276,7 +318,7 @@ const storedItems = (
276
318
  id,
277
319
  role: "assistant",
278
320
  createdAt: actionEntry?.createdAt ?? item.entries.at(-1)?.createdAt,
279
- class: isWideLayout(layout) ? "ai-chat-message-wide" : undefined,
321
+ class: isWideLayout(layout, error) ? "ai-chat-message-wide" : undefined,
280
322
  content: views.content({
281
323
  id,
282
324
  turnId,
@@ -285,6 +327,8 @@ const storedItems = (
285
327
  earlier: !last,
286
328
  duration: last ? () => ({ workedMs: item.workedMs, waitingMs: null }) : () => null,
287
329
  scheduledTask: task,
330
+ error,
331
+ continuable: Boolean(error) && newestLoop === item.loopId,
288
332
  }),
289
333
  actions: messageActions,
290
334
  actionDisplay: "inline",
@@ -300,6 +344,7 @@ const activeItems = (
300
344
  views: TurnViews,
301
345
  duration: Accessor<AiTurnDuration | null>,
302
346
  reconnecting: boolean,
347
+ locale: string,
303
348
  ): ChatTimelineItem[] => {
304
349
  if (!turn) return [];
305
350
  const segments = splitActiveTurnBlocks(turn.blocks).flatMap((segment): (AiActiveTurnSegment | SurveySegment)[] =>
@@ -340,7 +385,7 @@ const activeItems = (
340
385
  role: "user",
341
386
  status: block.status === "pending" ? "pending" : block.status === "failed" ? "error" : "complete",
342
387
  content: <p class="whitespace-pre-wrap">{aiSteerMessageText(block)}</p>,
343
- actions: createAiSteerMessageActions(block, actions),
388
+ actions: createAiSteerMessageActions(block, actions, locale),
344
389
  actionDisplay: "menu",
345
390
  anchorId: turn.seq,
346
391
  };
@@ -449,8 +494,9 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
449
494
  // While the stream reconnects or a model call waits for its retry, the live turn says so and its clock stands.
450
495
  const reconnecting = createMemo(() => source.activeTurn()?.providerRetry === true || source.reconnecting?.() === true);
451
496
  const clock = createActiveTurnClock(source, reconnecting);
452
- const stored = createMemo(() => storedItems(source.messages(), actions, views, locale()));
453
- const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock, reconnecting()));
497
+ const idle = createMemo(() => source.activeTurn() === null);
498
+ const stored = createMemo(() => storedItems(source.messages(), actions, views, locale(), idle()));
499
+ const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock, reconnecting(), locale()));
454
500
  return createMemo(() => {
455
501
  const items = [...stored(), ...active()];
456
502
  views.retain(new Set(items.map((item) => item.id)));
@@ -12,7 +12,7 @@ export function isFailedTool(tool: Tool): boolean {
12
12
  if (tool.isError || tool.status === "failed") return true;
13
13
  const result = tool.result;
14
14
  return (
15
- ["code_run", "code_inspect", "code_interact"].includes(tool.name) &&
15
+ ["code_run", "code_inspect"].includes(tool.name) &&
16
16
  result !== null &&
17
17
  typeof result === "object" &&
18
18
  "status" in result &&
@@ -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,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,7 @@ 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_inspect") return t.stepCheckingApp;
125
131
  if (name.startsWith("code_")) return t.stepCode;
126
132
  if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
127
133
  if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
@@ -165,7 +171,8 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
165
171
  const duration = worked();
166
172
  const short = duration === null ? "" : formatWorkDuration(duration, t());
167
173
  if (segment().phase === "stopped") return t().workedStopped({ duration: short });
168
- 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 });
169
176
  return short ? t().workedFor({ duration: short }) : t().worked;
170
177
  };
171
178
  const ariaLabel = () => {
@@ -175,7 +182,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
175
182
  return t().workedLabel({
176
183
  duration: duration === null ? "" : formatWorkDuration(duration, t(), true),
177
184
  steps: layout().steps ? steps() : "",
178
- ending: phase === "stopped" || phase === "failed" ? phase : null,
185
+ ending: phase === "stopped" || (phase === "failed" && !segment().error) ? phase : null,
179
186
  });
180
187
  };
181
188
  const trailing = () => {
@@ -522,6 +529,37 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
522
529
  );
523
530
  }
524
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
+
525
563
  /**
526
564
  * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
527
565
  * is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
@@ -605,6 +643,16 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
605
643
  const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
606
644
  const actionIds = createMemo(() => [...actions().keys()]);
607
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
+ );
608
656
  return (
609
657
  <AiToolDisclosureProvider state={props.disclosureState}>
610
658
  <div class="ai-turn">
@@ -638,6 +686,9 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
638
686
  {/* Calm by design: no busy sweep while the turn reconnects. */}
639
687
  <Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
640
688
  </Show>
689
+ <Show when={props.segment().error}>
690
+ {(error) => <AiTurnErrorNotice error={error()} continuable={props.segment().continuable === true} />}
691
+ </Show>
641
692
  </div>
642
693
  </AiToolDisclosureProvider>
643
694
  );