@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,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}`,
@@ -160,7 +179,10 @@ const messages = i18n.define({
160
179
  loadedCount: ({ count }: { count: number }) => `${count} loaded`,
161
180
  toolsLoaded: "Loaded",
162
181
  toolsAlreadyLoaded: "Already loaded",
163
- toolsMissing: "Missing",
182
+ toolUnknown: "Not found",
183
+ toolNotOffered: "Not available here",
184
+ toolNotAllowed: "Not allowed",
185
+ toolAppOffline: "App not reachable",
164
186
  toolsEvicted: "Evicted",
165
187
  loadedSkill: ({ name }: { name: string }) => `Loaded skill ${name}`,
166
188
  help: "Help",
@@ -184,6 +206,86 @@ const messages = i18n.define({
184
206
  calculate: "Calculate",
185
207
  inspectImage: ({ name }: { name: string }) => `Inspect image: ${name}`,
186
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",
187
289
  },
188
290
  de: {
189
291
  backgroundRun: "Hintergrundlauf",
@@ -204,6 +306,26 @@ const messages = i18n.define({
204
306
  thinking: "Denkt nach",
205
307
  showReasoning: "Denkprozess anzeigen",
206
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.",
207
329
  byteRange: ({ start, end }) => `Bytes ${start}–${end}`,
208
330
  worked: "Gearbeitet",
209
331
  workedFor: ({ duration }) => `${duration} gearbeitet`,
@@ -347,7 +469,10 @@ const messages = i18n.define({
347
469
  loadedCount: ({ count }) => `${count} geladen`,
348
470
  toolsLoaded: "Geladen",
349
471
  toolsAlreadyLoaded: "Bereits geladen",
350
- toolsMissing: "Nicht gefunden",
472
+ toolUnknown: "Nicht gefunden",
473
+ toolNotOffered: "Hier nicht verfügbar",
474
+ toolNotAllowed: "Nicht erlaubt",
475
+ toolAppOffline: "App nicht erreichbar",
351
476
  toolsEvicted: "Entladen",
352
477
  loadedSkill: ({ name }) => `Skill ${name} geladen`,
353
478
  help: "Hilfe",
@@ -371,6 +496,85 @@ const messages = i18n.define({
371
496
  calculate: "Berechnen",
372
497
  inspectImage: ({ name }) => `Bild prüfen: ${name}`,
373
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",
374
578
  },
375
579
  },
376
580
  });
@@ -13,11 +13,12 @@ import {
13
13
  Show,
14
14
  } from "solid-js";
15
15
  import type { AiActiveTurn } from "../client/projection";
16
- import { type AiActiveTurnSegment, splitActiveTurnBlocks } from "../protocol";
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",
@@ -299,6 +343,8 @@ const activeItems = (
299
343
  actions: AiChatActions,
300
344
  views: TurnViews,
301
345
  duration: Accessor<AiTurnDuration | null>,
346
+ reconnecting: boolean,
347
+ locale: string,
302
348
  ): ChatTimelineItem[] => {
303
349
  if (!turn) return [];
304
350
  const segments = splitActiveTurnBlocks(turn.blocks).flatMap((segment): (AiActiveTurnSegment | SurveySegment)[] =>
@@ -314,6 +360,14 @@ const activeItems = (
314
360
  // Until the model takes up the steering message, the segment above it is still the one at work.
315
361
  const above = steerTail ? segments.findLastIndex((segment, index) => segment.type === "assistant" && index < lastAssistant) : -1;
316
362
  const working = above >= 0 ? above : lastAssistant;
363
+ const phaseOf = (index: number): AiTurnPhase =>
364
+ index !== working ? "completed" : turn.status === "waiting_for_action" ? "waiting" : "running";
365
+ const layoutOf = (blocks: AiTurnBlock[], index: number) =>
366
+ layoutAiTurn(blocks, { phase: phaseOf(index), codePresentations: Boolean(actions.renderCodePresentation) });
367
+ // The work line says in place that the turn reconnects. A turn without one yet says it in a row at its end, where a
368
+ // row that comes and goes moves nothing above it.
369
+ const workingSegment = segments[working];
370
+ const reconnectInLine = reconnecting && workingSegment?.type === "assistant" && layoutOf(workingSegment.blocks, working).showWork;
317
371
  const segmentId = createSegmentIds();
318
372
  let opener = "start";
319
373
 
@@ -331,7 +385,7 @@ const activeItems = (
331
385
  role: "user",
332
386
  status: block.status === "pending" ? "pending" : block.status === "failed" ? "error" : "complete",
333
387
  content: <p class="whitespace-pre-wrap">{aiSteerMessageText(block)}</p>,
334
- actions: createAiSteerMessageActions(block, actions),
388
+ actions: createAiSteerMessageActions(block, actions, locale),
335
389
  actionDisplay: "menu",
336
390
  anchorId: turn.seq,
337
391
  };
@@ -339,8 +393,8 @@ const activeItems = (
339
393
 
340
394
  const last = index === lastAssistant;
341
395
  const live = index === working;
342
- const phase: AiTurnPhase = !live ? "completed" : turn.status === "waiting_for_action" ? "waiting" : "running";
343
- const layout = layoutAiTurn(segment.blocks, { phase, codePresentations: Boolean(actions.renderCodePresentation) });
396
+ const phase = phaseOf(index);
397
+ const layout = layoutOf(segment.blocks, index);
344
398
  // Consecutive steering messages leave empty segments between them; history has none. The last segment stays, since
345
399
  // it carries the progress indicator.
346
400
  if (!last && isEmptyLayout(layout)) return [];
@@ -358,7 +412,7 @@ const activeItems = (
358
412
  layout,
359
413
  earlier: !live,
360
414
  duration: live ? duration : () => null,
361
- retrying: last && turn.providerRetry === true,
415
+ reconnect: live && reconnectInLine ? "line" : last && reconnecting && !reconnectInLine ? "row" : undefined,
362
416
  }),
363
417
  };
364
418
  });
@@ -367,9 +421,10 @@ const activeItems = (
367
421
  /**
368
422
  * Work time of the active turn: wall time minus time spent waiting for the user. A state snapshot seeds it after a
369
423
  * reconnect; the clock stands while the turn waits for an approval or an answer and continues from the same value
370
- * afterwards.
424
+ * afterwards. While `paused`, the shown time stands too and then jumps to the true work time; the clock itself keeps
425
+ * running, so a step that starts right after the pause is timed from the true work time.
371
426
  */
372
- const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDuration | null> => {
427
+ const createActiveTurnClock = (source: AiChatTimelineSource, paused: Accessor<boolean>): Accessor<AiTurnDuration | null> => {
373
428
  const [now, setNow] = createSignal(Date.now());
374
429
  const running = createMemo(() => source.activeTurn() !== null);
375
430
  createEffect(() => {
@@ -379,7 +434,7 @@ const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDur
379
434
  onCleanup(() => window.clearInterval(timer));
380
435
  });
381
436
  let state: { turnId: string; seed: string; startedAt: number; waitMs: number; waitingSince: number | null } | null = null;
382
- return createMemo(() => {
437
+ const duration = createMemo(() => {
383
438
  const turn = source.activeTurn();
384
439
  const time = now();
385
440
  if (!turn) {
@@ -411,11 +466,19 @@ const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDur
411
466
  waitingMs: state.waitingSince === null ? null : Math.max(0, time - state.waitingSince),
412
467
  };
413
468
  });
469
+ let shown: AiTurnDuration | null = null;
470
+ return createMemo(() => {
471
+ const current = duration();
472
+ if (!paused() || !shown || !current) shown = current;
473
+ return shown;
474
+ });
414
475
  };
415
476
 
416
477
  export type AiChatTimelineSource = {
417
478
  messages: Accessor<readonly AiStoredMessage[]>;
418
479
  activeTurn: Accessor<AiActiveTurn | null>;
480
+ /** The conversation stream lost its connection and reconnects, such as `streamStatus() === "reconnecting"`. */
481
+ reconnecting?: Accessor<boolean>;
419
482
  };
420
483
 
421
484
  /**
@@ -428,9 +491,12 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
428
491
  const locale = useLocale();
429
492
  const disclosureState = createAiToolDisclosureState();
430
493
  const views = createTurnViews(disclosureState);
431
- const clock = createActiveTurnClock(source);
432
- const stored = createMemo(() => storedItems(source.messages(), actions, views, locale()));
433
- const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock));
494
+ // While the stream reconnects or a model call waits for its retry, the live turn says so and its clock stands.
495
+ const reconnecting = createMemo(() => source.activeTurn()?.providerRetry === true || source.reconnecting?.() === true);
496
+ const clock = createActiveTurnClock(source, 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()));
434
500
  return createMemo(() => {
435
501
  const items = [...stored(), ...active()];
436
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);