@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
@@ -7,15 +7,20 @@ import {
7
7
  PanelDialog,
8
8
  panelDialogFixedOptions,
9
9
  prompts,
10
- StatCell,
11
- StatGrid,
12
10
  TextInput,
13
11
  useLocale,
14
12
  } from "@k2b/ui";
15
13
  import { createContext, createSignal, For, type JSX, Show, useContext } from "solid-js";
16
14
  import type { AiTurnBlock } from "../protocol";
17
15
  import type { AiMessageFeedback, AiMessageFeedbackReason, AiStoredMessage } from "../types";
18
- import { type AiForkMessageInput, type AiRetryMessageInput, aiToolIcon, displayToolName, formatWorkedDuration } from "./message-utils";
16
+ import {
17
+ type AiForkMessageInput,
18
+ type AiRetryMessageInput,
19
+ aiToolIcon,
20
+ describeResponseEnd,
21
+ displayToolName,
22
+ formatWorkedDuration,
23
+ } from "./message-utils";
19
24
  import { aiChatMessages } from "./messages";
20
25
 
21
26
  /** The active-turn coordinates an approval/tool action needs to resolve on the server. */
@@ -41,6 +46,11 @@ export type AiChatActions = {
41
46
  onForkMessage?: ForkMessageHandler;
42
47
  onRetryMessage?: RetryMessageHandler;
43
48
  onRetrySteer?: RetrySteerHandler;
49
+ /**
50
+ * Continue after a failed turn. Send `message`, a visible request in the reader's language to pick up where the
51
+ * turn stopped, through the host's normal send path. Without it, a failed turn shows its reason only.
52
+ */
53
+ onContinueTurn?: (message: string) => void | Promise<void>;
44
54
  onMessageFeedback?: (entry: AiStoredMessage, feedback: Omit<AiMessageFeedback, "updatedAt"> | null) => void | Promise<void>;
45
55
  /** Open the originating background run for a delivered scheduled result. */
46
56
  onOpenScheduledTaskRun?: (taskId: string, occurrenceId: string) => void;
@@ -72,6 +82,7 @@ const formatDateTime = (value: string, locale: string) =>
72
82
  new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
73
83
 
74
84
  const assistantResponseInfo = (entries: AiStoredMessage[], locale: string) => {
85
+ const t = aiChatMessages(locale);
75
86
  const assistantEntries = entries.filter((entry) => entry.kind === "message" && entry.message.role === "assistant");
76
87
  const entry = assistantEntries.findLast((candidate) => candidate.loopAggregate) ?? assistantEntries.at(-1) ?? entries.at(-1);
77
88
  if (!entry) return null;
@@ -83,18 +94,18 @@ const assistantResponseInfo = (entries: AiStoredMessage[], locale: string) => {
83
94
  const timing = aggregate?.timing;
84
95
 
85
96
  const meta = [
86
- { label: "Provider model", value: entry.providerModel ?? "Unknown" },
87
- { label: "Model profile", value: entry.modelProfileId ?? "Unknown" },
88
- { label: "Loop id", value: entry.loopId ?? "Legacy message" },
89
- { label: "Finished", value: `${entry.loopDoneReason ?? "unknown"} · ${entry.stopReason ?? "unknown"}` },
90
- { label: "Created", value: formatDateTime(entry.createdAt, locale) },
97
+ { label: t.infoProviderModel, value: entry.providerModel ?? t.infoUnknown },
98
+ { label: t.infoModelProfile, value: entry.modelProfileId ?? t.infoUnknown },
99
+ { label: t.infoLoopId, value: entry.loopId ?? t.infoLegacy },
100
+ { label: t.infoFinished, value: describeResponseEnd(entry, locale) },
101
+ { label: t.infoCreated, value: formatDateTime(entry.createdAt, locale) },
91
102
  ];
92
103
 
93
104
  const issues = [
94
- { label: "errors", count: aggregate?.toolErrorCount ?? 0 },
95
- { label: "stream issues", count: aggregate?.toolIssueCount ?? 0 },
96
- { label: "malformed", count: aggregate?.toolMalformedCount ?? 0 },
97
- { label: "cancelled", count: aggregate?.toolCancelledCount ?? 0 },
105
+ { label: t.infoIssueErrors, count: aggregate?.toolErrorCount ?? 0 },
106
+ { label: t.infoIssueStream, count: aggregate?.toolIssueCount ?? 0 },
107
+ { label: t.infoIssueMalformed, count: aggregate?.toolMalformedCount ?? 0 },
108
+ { label: t.infoIssueCancelled, count: aggregate?.toolCancelledCount ?? 0 },
98
109
  ].filter((issue) => issue.count > 0);
99
110
 
100
111
  const toolCounts = new Map<string, number>();
@@ -117,6 +128,27 @@ const assistantResponseInfo = (entries: AiStoredMessage[], locale: string) => {
117
128
  };
118
129
  };
119
130
 
131
+ /** Label and value pairs in one quiet list; a hint explains a value in muted text below it. */
132
+ function InfoRows(props: { rows: readonly { label: string; value: string; hint?: string }[] }) {
133
+ return (
134
+ <dl class="ai-response-info__rows">
135
+ <For each={props.rows}>
136
+ {(row) => (
137
+ <div>
138
+ <dt>
139
+ {row.label}
140
+ <Show when={row.hint}>
141
+ <span>{row.hint}</span>
142
+ </Show>
143
+ </dt>
144
+ <dd title={row.value}>{row.value}</dd>
145
+ </div>
146
+ )}
147
+ </For>
148
+ </dl>
149
+ );
150
+ }
151
+
120
152
  const openAssistantResponseInfo = (entries: AiStoredMessage[], locale: string) => {
121
153
  const info = assistantResponseInfo(entries, locale);
122
154
  if (!info) return;
@@ -130,85 +162,70 @@ const openAssistantResponseInfo = (entries: AiStoredMessage[], locale: string) =
130
162
  void dialogCore.open<void>(
131
163
  (close) => (
132
164
  <PanelDialog>
133
- <PanelDialog.Header title="Message info" subtitle="Assistant response metadata" icon="ti ti-info-circle" close={close} />
165
+ <PanelDialog.Header title={t.actionMessageInfo} subtitle={t.infoSubtitle} icon="ti ti-info-circle" close={close} />
134
166
  <PanelDialog.Body>
135
- <PanelDialog.Section title="Details" icon="ti ti-list-details">
136
- <dl class="grid grid-cols-[auto_1fr] items-baseline gap-x-6 gap-y-1.5 text-sm">
137
- <For each={info.meta}>
138
- {(row) => (
139
- <>
140
- <dt class="text-dimmed">{row.label}</dt>
141
- <dd class="min-w-0 truncate text-right text-primary" title={row.value}>
142
- {row.value}
143
- </dd>
144
- </>
145
- )}
146
- </For>
147
- </dl>
167
+ <PanelDialog.Section title={t.infoDetails} icon="ti ti-list-details">
168
+ <InfoRows rows={info.meta} />
148
169
  </PanelDialog.Section>
149
170
 
150
171
  <Show when={info.timing}>
151
172
  {(timing) => (
152
- <StatGrid title="Timing" columns={3} surface="muted">
153
- {/* Worked = generation + tool execution; waiting for approvals/client tools is listed separately. */}
154
- <StatCell label="Worked" value={formatWorkedDuration(timing().totalElapsedMs)} sub="generation + tools" />
155
- <StatCell label="Generation" value={formatWorkedDuration(timing().generationMs)} />
156
- <StatCell
157
- label="Tool execution"
158
- value={timing().toolExecutionMs > 0 ? formatWorkedDuration(timing().toolExecutionMs) : "–"}
159
- />
160
- <StatCell
161
- label="Waiting for user"
162
- value={timing().actionWaitMs > 0 ? formatWorkedDuration(timing().actionWaitMs) : "–"}
163
- sub="approvals & inputs"
164
- />
165
- <StatCell label="Wall clock" value={formatWorkedDuration(timing().wallMs)} />
166
- <StatCell
167
- label="Output speed"
168
- value={
169
- timing().outputTokensPerSecond !== undefined
170
- ? `${timing().outputTokensPerSecond?.toLocaleString(locale, { maximumFractionDigits: 1 })} tok/s`
171
- : "–"
172
- }
173
+ <PanelDialog.Section title={t.infoTiming} icon="ti ti-clock">
174
+ {/* Worked = writing + tool execution; waiting for approvals and answers is listed separately. */}
175
+ <InfoRows
176
+ rows={[
177
+ { label: t.infoWorked, value: formatWorkedDuration(timing().totalElapsedMs), hint: t.infoWorkedHint },
178
+ { label: t.infoGeneration, value: formatWorkedDuration(timing().generationMs) },
179
+ { label: t.infoToolTime, value: timing().toolExecutionMs > 0 ? formatWorkedDuration(timing().toolExecutionMs) : "–" },
180
+ {
181
+ label: t.infoWaiting,
182
+ value: timing().actionWaitMs > 0 ? formatWorkedDuration(timing().actionWaitMs) : "–",
183
+ hint: t.infoWaitingHint,
184
+ },
185
+ { label: t.infoWallClock, value: formatWorkedDuration(timing().wallMs) },
186
+ {
187
+ label: t.infoSpeed,
188
+ value:
189
+ timing().outputTokensPerSecond !== undefined
190
+ ? t.infoTokensPerSecond({
191
+ value: timing().outputTokensPerSecond!.toLocaleString(locale, { maximumFractionDigits: 1 }),
192
+ })
193
+ : "–",
194
+ },
195
+ ]}
173
196
  />
174
- </StatGrid>
197
+ </PanelDialog.Section>
175
198
  )}
176
199
  </Show>
177
200
 
178
- <StatGrid title="Usage" columns={3} surface="muted">
179
- <StatCell label="Input tokens" value={tokens(info.usage.input)} />
180
- <StatCell label="Output tokens" value={tokens(info.usage.output)} />
181
- <StatCell label="Total tokens" value={tokens(info.usage.total)} />
182
- <StatCell label={t.assistantTurns} value={number(info.turns)} />
183
- <StatCell label={t.toolCalls} value={number(info.toolCallCount)} />
184
- <Show
185
- when={info.issues.length > 0}
186
- fallback={<StatCell label={t.toolIssues} value={t.none} accent={{ tone: "emerald", icon: "ti ti-check", text: "ok" }} />}
187
- >
188
- <StatCell
189
- label={t.toolIssues}
190
- value={number(issueTotal)}
191
- sub={issueSummary}
192
- accent={{ tone: "red", icon: "ti ti-alert-triangle" }}
193
- />
194
- </Show>
195
- </StatGrid>
201
+ <PanelDialog.Section title={t.infoUsage} icon="ti ti-chart-bar">
202
+ <InfoRows
203
+ rows={[
204
+ { label: t.infoInputTokens, value: tokens(info.usage.input) },
205
+ { label: t.infoOutputTokens, value: tokens(info.usage.output) },
206
+ { label: t.infoTotalTokens, value: tokens(info.usage.total) },
207
+ { label: t.assistantTurns, value: number(info.turns) },
208
+ { label: t.toolCalls, value: number(info.toolCallCount) },
209
+ { label: t.toolIssues, value: info.issues.length > 0 ? `${number(issueTotal)} · ${issueSummary}` : t.none },
210
+ ]}
211
+ />
212
+ </PanelDialog.Section>
196
213
 
197
214
  <Show when={info.tools.length > 0}>
198
- <PanelDialog.Section title="Tools" icon="ti ti-tool" subtitle="Tools requested by this assistant loop.">
199
- <div class="flex flex-wrap gap-1.5">
215
+ <PanelDialog.Section title={t.infoTools} icon="ti ti-tool" subtitle={t.infoToolsHint}>
216
+ <ul class="ai-response-info__tools">
200
217
  <For each={info.tools}>
201
218
  {(tool) => (
202
- <span class="inline-flex items-center gap-1.5 rounded-md bg-zinc-100 px-2 py-1 text-xs text-secondary dark:bg-zinc-900">
203
- <i class={`${aiToolIcon(tool.name)} text-sm`} aria-hidden="true" />
219
+ <li>
220
+ <i class={aiToolIcon(tool.name)} aria-hidden="true" />
204
221
  {displayToolName(tool.name)}
205
222
  <Show when={tool.count > 1}>
206
- <span class="rounded bg-white px-1 font-medium tabular-nums text-dimmed dark:bg-zinc-950">×{tool.count}</span>
223
+ <span class="text-dimmed tabular-nums">×{tool.count}</span>
207
224
  </Show>
208
- </span>
225
+ </li>
209
226
  )}
210
227
  </For>
211
- </div>
228
+ </ul>
212
229
  </PanelDialog.Section>
213
230
  </Show>
214
231
  </PanelDialog.Body>
@@ -218,12 +235,12 @@ const openAssistantResponseInfo = (entries: AiStoredMessage[], locale: string) =
218
235
  );
219
236
  };
220
237
 
221
- const forkTitleFromText = (text: string): string => {
238
+ const forkTitleFromText = (text: string, fallback: string): string => {
222
239
  const firstLine = text
223
240
  .split("\n")
224
241
  .map((line) => line.trim())
225
242
  .find(Boolean);
226
- if (!firstLine) return "Forked chat";
243
+ if (!firstLine) return fallback;
227
244
  return firstLine.length > 80 ? `${firstLine.slice(0, 77).trim()}...` : firstLine;
228
245
  };
229
246
 
@@ -231,17 +248,19 @@ const openForkMessageDialog = async (
231
248
  entry: AiStoredMessage,
232
249
  copyText: string,
233
250
  onForkMessage: (entry: AiStoredMessage, input?: AiForkMessageInput) => void | Promise<void>,
251
+ locale: string,
234
252
  ) => {
253
+ const t = aiChatMessages(locale);
235
254
  const result = await prompts.form({
236
- title: "Fork chat",
255
+ title: t.forkTitle,
237
256
  icon: "ti ti-git-fork",
238
- confirmText: "Fork",
257
+ confirmText: t.forkConfirm,
239
258
  size: "medium",
240
259
  fields: {
241
260
  title: {
242
261
  type: "text",
243
- label: "Chat name",
244
- default: forkTitleFromText(copyText),
262
+ label: t.forkName,
263
+ default: forkTitleFromText(copyText, t.forkDefaultName),
245
264
  required: true,
246
265
  maxLength: 120,
247
266
  },
@@ -251,16 +270,18 @@ const openForkMessageDialog = async (
251
270
  if (title) await onForkMessage(entry, { title });
252
271
  };
253
272
 
254
- const feedbackReasons: readonly { id: AiMessageFeedbackReason; label: string; description: string }[] = [
255
- { id: "incorrect", label: "Incorrect", description: "Facts or conclusions were wrong." },
256
- { id: "did_not_follow_request", label: "Did not follow request", description: "The response missed important instructions." },
257
- { id: "incomplete", label: "Incomplete", description: "Important information or work was missing." },
258
- { id: "poor_tool_choice", label: "Poor tool choice", description: "A capability was missing, unnecessary, or used badly." },
259
- { id: "too_slow", label: "Too slow", description: "The response took too long for the result." },
260
- { id: "other", label: "Other", description: "Something else should be improved." },
273
+ const feedbackReasons = (
274
+ t: ReturnType<typeof aiChatMessages>,
275
+ ): readonly { id: AiMessageFeedbackReason; label: string; description: string }[] => [
276
+ { id: "incorrect", label: t.feedbackIncorrect, description: t.feedbackIncorrectHint },
277
+ { id: "did_not_follow_request", label: t.feedbackIgnoredRequest, description: t.feedbackIgnoredRequestHint },
278
+ { id: "incomplete", label: t.feedbackIncomplete, description: t.feedbackIncompleteHint },
279
+ { id: "poor_tool_choice", label: t.feedbackWrongApp, description: t.feedbackWrongAppHint },
280
+ { id: "too_slow", label: t.feedbackSlow, description: t.feedbackSlowHint },
281
+ { id: "other", label: t.feedbackOther, description: t.feedbackOtherHint },
261
282
  ];
262
283
 
263
- const openNegativeFeedbackDialog = () =>
284
+ const openNegativeFeedbackDialog = (t: ReturnType<typeof aiChatMessages>) =>
264
285
  prompts.dialog<{ reasons: AiMessageFeedbackReason[]; comment: string | null } | undefined>(
265
286
  (close) => {
266
287
  const [selected, setSelected] = createSignal<AiMessageFeedbackReason[]>([]);
@@ -270,9 +291,9 @@ const openNegativeFeedbackDialog = () =>
270
291
  const valid = () => selected().length > 0 || comment().trim().length > 0;
271
292
  return (
272
293
  <div class="flex min-h-0 flex-col gap-4">
273
- <p class="text-sm text-secondary">What should be improved? Choose all that apply or leave a short note.</p>
294
+ <p class="text-sm text-secondary">{t.feedbackIntro}</p>
274
295
  <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
275
- <For each={feedbackReasons}>
296
+ <For each={feedbackReasons(t)}>
276
297
  {(reason) => (
277
298
  <CheckboxCard
278
299
  label={reason.label}
@@ -284,8 +305,8 @@ const openNegativeFeedbackDialog = () =>
284
305
  </For>
285
306
  </div>
286
307
  <TextInput
287
- label="Additional details"
288
- description="Optional. Do not include sensitive information."
308
+ label={t.feedbackDetails}
309
+ description={t.feedbackDetailsHint}
289
310
  multiline
290
311
  lines={3}
291
312
  maxLength={1_000}
@@ -294,7 +315,7 @@ const openNegativeFeedbackDialog = () =>
294
315
  />
295
316
  <div class="flex justify-end gap-2">
296
317
  <Button variant="secondary" size="sm" type="button" onClick={() => close(undefined)}>
297
- Cancel
318
+ {t.cancel}
298
319
  </Button>
299
320
  <Button
300
321
  size="sm"
@@ -302,13 +323,13 @@ const openNegativeFeedbackDialog = () =>
302
323
  disabled={!valid()}
303
324
  onClick={() => close({ reasons: selected(), comment: comment().trim() || null })}
304
325
  >
305
- Send feedback
326
+ {t.feedbackSend}
306
327
  </Button>
307
328
  </div>
308
329
  </div>
309
330
  );
310
331
  },
311
- { title: "Help improve this response", icon: "ti ti-message-report", size: "large" },
332
+ { title: t.feedbackTitle, icon: "ti ti-message-report", size: "large" },
312
333
  );
313
334
 
314
335
  export function createAssistantMessageActions(props: {
@@ -318,13 +339,14 @@ export function createAssistantMessageActions(props: {
318
339
  actions: AiChatActions;
319
340
  }): ChatAction[] {
320
341
  const locale = useLocale();
342
+ const t = aiChatMessages(locale());
321
343
  const actions = props.actions;
322
344
  const result: ChatAction[] = [
323
345
  ...(actions.onMessageFeedback
324
346
  ? [
325
347
  {
326
348
  id: "feedback-up",
327
- label: props.entry.feedback?.rating === "up" ? "Remove positive feedback" : "Helpful",
349
+ label: props.entry.feedback?.rating === "up" ? t.actionHelpfulRemove : t.actionHelpful,
328
350
  icon: "ti ti-thumb-up",
329
351
  pressed: props.entry.feedback?.rating === "up",
330
352
  pressedTone: "success" as const,
@@ -336,13 +358,13 @@ export function createAssistantMessageActions(props: {
336
358
  },
337
359
  {
338
360
  id: "feedback-down",
339
- label: props.entry.feedback?.rating === "down" ? "Remove negative feedback" : "Needs improvement",
361
+ label: props.entry.feedback?.rating === "down" ? t.actionImproveRemove : t.actionImprove,
340
362
  icon: "ti ti-thumb-down",
341
363
  pressed: props.entry.feedback?.rating === "down",
342
364
  pressedTone: "danger" as const,
343
365
  onSelect: async () => {
344
366
  if (props.entry.feedback?.rating === "down") return actions.onMessageFeedback!(props.entry, null);
345
- const feedback = await openNegativeFeedbackDialog();
367
+ const feedback = await openNegativeFeedbackDialog(t);
346
368
  if (feedback) await actions.onMessageFeedback!(props.entry, { rating: "down", ...feedback });
347
369
  },
348
370
  },
@@ -350,7 +372,7 @@ export function createAssistantMessageActions(props: {
350
372
  : []),
351
373
  {
352
374
  id: "info",
353
- label: "Message info",
375
+ label: t.actionMessageInfo,
354
376
  icon: "ti ti-info-circle",
355
377
  onSelect: () => openAssistantResponseInfo(props.entries, locale()),
356
378
  },
@@ -358,7 +380,7 @@ export function createAssistantMessageActions(props: {
358
380
  if (props.copyText) {
359
381
  result.push({
360
382
  id: "copy",
361
- label: "Copy",
383
+ label: t.actionCopy,
362
384
  icon: "ti ti-copy",
363
385
  copyText: props.copyText,
364
386
  });
@@ -366,9 +388,9 @@ export function createAssistantMessageActions(props: {
366
388
  if (actions.onForkMessage) {
367
389
  result.push({
368
390
  id: "fork",
369
- label: "Fork conversation",
391
+ label: t.actionFork,
370
392
  icon: "ti ti-git-fork",
371
- onSelect: () => openForkMessageDialog(props.entry, props.copyText, actions.onForkMessage!),
393
+ onSelect: () => openForkMessageDialog(props.entry, props.copyText, actions.onForkMessage!, locale()),
372
394
  });
373
395
  }
374
396
  return result;
@@ -1,4 +1,4 @@
1
- import type { Message, Usage } from "@k2b/nessi";
1
+ import type { DoneReason, Message, Usage } from "@k2b/nessi";
2
2
  import { fileIcons } from "@k2b/stdlib";
3
3
  import { formatBytes as sharedFormatBytes } from "../../shared/format";
4
4
  import { type AiAttachmentRef, parseAiAttachmentMarkers } from "../attachments";
@@ -122,6 +122,35 @@ export const FILE_INPUT_ACCEPT = [
122
122
  ].join(",");
123
123
 
124
124
  /** Seconds-granular work duration ("8s", "2m 14s") — stdlib's dates.formatDuration is deliberately minute-granular. */
125
+ /**
126
+ * How a response ended, in words: the run's end, plus the last message's own stop when it adds something, such as a
127
+ * cut-off answer. A stop code the list does not know stays as the provider sent it.
128
+ */
129
+ export const describeResponseEnd = (entry: Pick<AiStoredMessage, "loopDoneReason" | "stopReason">, locale: string): string => {
130
+ const t = aiChatMessages(locale);
131
+ const run: Record<DoneReason, string> = {
132
+ stop: t.infoEndCompleted,
133
+ aborted: t.infoEndStopped,
134
+ error: t.infoEndFailed,
135
+ max_turns: t.infoEndStepLimit,
136
+ no_credits: t.infoEndUsageLimit,
137
+ context_overflow: t.infoEndContextFull,
138
+ };
139
+ const message: Record<string, string | null> = {
140
+ stop: entry.loopDoneReason ? null : t.infoEndCompleted,
141
+ tool_use: null,
142
+ max_tokens: t.infoEndLengthLimit,
143
+ aborted: t.infoEndStopped,
144
+ interrupted: t.infoEndInterrupted,
145
+ error: t.infoEndFailed,
146
+ };
147
+ const parts = [
148
+ entry.loopDoneReason ? run[entry.loopDoneReason] : null,
149
+ entry.stopReason ? (Object.hasOwn(message, entry.stopReason) ? message[entry.stopReason] : entry.stopReason) : null,
150
+ ].filter((part): part is string => Boolean(part));
151
+ return [...new Set(parts)].join(" · ") || t.infoUnknown;
152
+ };
153
+
125
154
  export const formatWorkedDuration = (ms: number): string => {
126
155
  const totalSeconds = Math.max(1, Math.round(ms / 1000));
127
156
  const hours = Math.floor(totalSeconds / 3600);