@k2b/cloud 0.28.0 → 0.29.1

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 (112) hide show
  1. package/package.json +9 -6
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
@@ -10,14 +10,20 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
10
10
  * The live chat timeline for a browser test, folded from wire events like the
11
11
  * controller does. `window.emit(event)` applies one event,
12
12
  * `window.steer(text)` appends a pending steer as the controller does,
13
- * `window.reconnect(on)` sets whether the stream reconnects, and
14
- * `window.continued` lists the messages that **Continue** sent.
13
+ * `window.reconnect(on)` sets whether the stream reconnects,
14
+ * `window.continued` lists the messages that **Continue** sent,
15
+ * `window.approvals` lists the approval decisions, and
16
+ * `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
15
17
  */
16
18
  const [state, setState] = createStore(emptyProjection());
17
19
  const [reconnecting, setReconnecting] = createSignal(false);
18
20
  const continued: string[] = [];
21
+ const opened: string[] = [];
22
+ const approvals: unknown[] = [];
19
23
  Object.assign(window, {
20
24
  continued,
25
+ approvals,
26
+ opened,
21
27
  reconnect: (on: boolean) => setReconnecting(on),
22
28
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
23
29
  steer: (text: string) =>
@@ -32,7 +38,14 @@ root.style.cssText = "display: flex; flex-direction: column; height: 100dvh; max
32
38
  document.body.append(root);
33
39
  render(
34
40
  () => (
35
- <AiChatActionsProvider actions={{ onContinueTurn: (message) => void continued.push(message) }}>
41
+ <AiChatActionsProvider
42
+ actions={{
43
+ onContinueTurn: (message) => void continued.push(message),
44
+ onApproval: async (request, input) => void approvals.push({ callId: request.callId, ...input }),
45
+ fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
46
+ onOpenFile: (path) => void opened.push(path),
47
+ }}
48
+ >
36
49
  {(() => {
37
50
  const items = createAiChatTimeline({
38
51
  messages: createMemo(() => visibleMessages(state)),
@@ -218,7 +218,7 @@ const openAssistantResponseInfo = (entries: AiStoredMessage[], locale: string) =
218
218
  {(tool) => (
219
219
  <li>
220
220
  <i class={aiToolIcon(tool.name)} aria-hidden="true" />
221
- {displayToolName(tool.name)}
221
+ {displayToolName(tool.name, locale)}
222
222
  <Show when={tool.count > 1}>
223
223
  <span class="text-dimmed tabular-nums">×{tool.count}</span>
224
224
  </Show>
@@ -3,12 +3,14 @@ import { fileIcons } from "@k2b/stdlib";
3
3
  import { formatBytes as sharedFormatBytes } from "../../shared/format";
4
4
  import { type AiAttachmentRef, parseAiAttachmentMarkers } from "../attachments";
5
5
  import { AI_AUDIO_EXTENSIONS } from "../audio-format";
6
+ import { aiProjectFilePathFromMount, aiSkillFilePathFromMount } from "../file-mount";
6
7
  import { AI_IMAGE_INPUT_MAX_BYTES, AI_TURN_ATTACHMENT_MAX_ITEMS } from "../limits";
7
8
  import { type AiResourceMarker, parseAiResourceMarker } from "../resource-markers";
8
9
  import { assistantVisibleTextFromMessage } from "../timeline";
9
10
  import type { AiStoredMessage, AiUserContentPart } from "../types";
10
11
  import { AI_IMAGE_MEDIA_TYPES, isAiImageMediaType } from "../types";
11
12
  import { aiChatMessages } from "./messages";
13
+ import { aiBuiltinToolName } from "./tool-names";
12
14
 
13
15
  type AssistantToolResultMessage = Extract<Message, { role: "tool_result" }>;
14
16
 
@@ -144,8 +146,9 @@ export const describeResponseEnd = (entry: Pick<AiStoredMessage, "loopDoneReason
144
146
  interrupted: t.infoEndInterrupted,
145
147
  error: t.infoEndFailed,
146
148
  };
149
+ const stopped = entry.stopReason && ["error", "aborted", "interrupted"].includes(entry.stopReason);
147
150
  const parts = [
148
- entry.loopDoneReason ? run[entry.loopDoneReason] : null,
151
+ entry.loopDoneReason && !(entry.loopDoneReason === "stop" && stopped) ? run[entry.loopDoneReason] : null,
149
152
  entry.stopReason ? (Object.hasOwn(message, entry.stopReason) ? message[entry.stopReason] : entry.stopReason) : null,
150
153
  ].filter((part): part is string => Boolean(part));
151
154
  return [...new Set(parts)].join(" · ") || t.infoUnknown;
@@ -312,11 +315,10 @@ export const isSurveyToolName = (name: string) => name === "survey" || name ===
312
315
 
313
316
  export const isTextEditorToolName = (name: string) => name === "text_editor" || name === "cloud_text_editor";
314
317
 
315
- export const displayToolName = (name: string) => {
316
- if (isCardToolName(name)) return "card";
317
- if (isSurveyToolName(name)) return "survey";
318
- if (isTextEditorToolName(name)) return "text editor";
319
- if (name === "local_bash") return "Local Bash";
318
+ /** A tool's name for people: the localized name of a built-in tool, otherwise its name in words. */
319
+ export const displayToolName = (name: string, locale: string) => {
320
+ const builtin = aiBuiltinToolName(name, locale);
321
+ if (builtin) return builtin;
320
322
  const words = name.split("_").filter(Boolean).join(" ");
321
323
  return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}`;
322
324
  };
@@ -340,6 +342,7 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
340
342
  ["web_search", "ti ti-search"],
341
343
  ["web_extract", "ti ti-world-download"],
342
344
  ["view_image", "ti ti-photo-spark"],
345
+ ["code_check", "ti ti-checklist"],
343
346
  ["memory", "ti ti-brain"],
344
347
  ["search_project", "ti ti-folder-search"],
345
348
  ["read_project_knowledge", "ti ti-notebook"],
@@ -353,6 +356,25 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
353
356
  ["read_cloud_resource", "ti ti-ai-gateway"],
354
357
  ]);
355
358
 
359
+ /** What an app check found: errors stop it from passing, warnings are counted separately. */
360
+ export type AiCodeCheckOutcome = { passed: boolean; errors: number; warnings: number };
361
+
362
+ export const codeCheckOutcome = (name: string, result: unknown): AiCodeCheckOutcome | null => {
363
+ if (name !== "code_check" || !isRecord(result) || typeof result.passed !== "boolean") return null;
364
+ const issues = Array.isArray(result.issues) ? result.issues.filter(isRecord) : [];
365
+ const warnings = issues.filter((issue) => issue.severity === "warning").length;
366
+ return { passed: result.passed, errors: issues.length - warnings, warnings };
367
+ };
368
+
369
+ /** The chat image a view_image call looked at. Project and skill files are not chat files and have no preview here. */
370
+ export const viewedChatImage = (name: string, result: unknown): { path: string; description: string } | null => {
371
+ if (name !== "view_image" || !isRecord(result)) return null;
372
+ const { path, mediaType, description } = result;
373
+ if (typeof path !== "string" || typeof mediaType !== "string" || !isAiImageMediaType(mediaType)) return null;
374
+ if (aiProjectFilePathFromMount(path) !== null || aiSkillFilePathFromMount(path) !== null) return null;
375
+ return { path, description: typeof description === "string" ? description.trim() : "" };
376
+ };
377
+
356
378
  export const aiToolIcon = (name: string, appIcon?: string | null): string => {
357
379
  if (appIcon) return appIcon;
358
380
  return BUILT_IN_TOOL_ICONS.get(name) ?? (/__(?:query|action)__/.test(name) ? "ti ti-ai-gateway" : "ti ti-tool");
@@ -25,6 +25,7 @@ const messages = i18n.define({
25
25
  turnErrorTitle: "The answer was interrupted.",
26
26
  turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
27
27
  if (code === "model_unavailable") return "The model service did not answer.";
28
+ if (code === "provider_stopped") return "The model provider ended this answer early.";
28
29
  if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
29
30
  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
31
  if (code === "time_limit")
@@ -71,6 +72,7 @@ const messages = i18n.define({
71
72
  stepWritingCode: "Writing code",
72
73
  stepRunningCode: "Running code",
73
74
  stepCheckingApp: "Checking the app",
75
+ stepCheckingRun: "Checking the run",
74
76
  stepCode: "Working with code",
75
77
  stepPlan: "Updating the working plan",
76
78
  stepPdf: "Creating a PDF",
@@ -87,23 +89,18 @@ const messages = i18n.define({
87
89
  stepFailed: "failed",
88
90
  stepRejected: "rejected",
89
91
  stepWaiting: "waiting",
92
+ checkPassed: "passed",
93
+ checkNotPassed: "not passed",
94
+ checkFindings: ({ count }: { count: number }) => (count === 1 ? "1 finding" : `${count} findings`),
95
+ checkWarnings: ({ count }: { count: number }) => (count === 1 ? "1 warning" : `${count} warnings`),
96
+ openImage: ({ name }: { name: string }) => `Open image ${name}`,
90
97
  steered: "Conversation steered",
91
- reasoning: "Reasoning",
92
- groupPlan: "Updated working plan",
93
- groupCode: "Worked with code",
94
- groupCloud: "Used Cloud integration",
95
- groupReadFiles: "Read files",
96
- groupWriteFiles: "Wrote files",
97
- groupPdf: "Created PDFs",
98
98
  groupLoad: "Loaded tools and guidance",
99
- groupWeb: "Searched the web",
100
- groupMemory: "Used memory",
101
- groupDelivered: "Delivered results",
102
- groupTools: "Used tools",
103
99
  groupFailed: ({ count }: { count: number }) => `${count} failed`,
104
100
  groupRejected: ({ count }: { count: number }) => `${count} rejected`,
105
101
  receiptDone: ({ title }: { title: string }) => `${title} done`,
106
102
  receiptApproved: ({ title }: { title: string }) => `Approved: ${title}`,
103
+ receiptRunning: ({ title }: { title: string }) => `Running: ${title}`,
107
104
  approvalNeeded: ({ title }: { title: string }) => `Approval needed: ${title}`,
108
105
  answerReady: "Answer ready",
109
106
  receiptRejected: ({ title }: { title: string }) => `Rejected: ${title}`,
@@ -114,7 +111,7 @@ const messages = i18n.define({
114
111
  streamAccessDenied: "You no longer have access to this chat.",
115
112
  streamNotFound: "This chat is no longer available.",
116
113
  assistant: "Assistant",
117
- action: "Action",
114
+ approvalRunsAfter: "Runs only after you approve it",
118
115
  approvalRequired: ({ title }: { title: string }) => `Approval required: ${title}`,
119
116
  reject: "Reject",
120
117
  approved: "Approved",
@@ -286,6 +283,7 @@ const messages = i18n.define({
286
283
  `${count === 1 ? "1 message" : `${count} messages`} summarized · ${date}`,
287
284
  compactedHint: "From here on, the model sees this summary instead of the messages above.",
288
285
  compactedEmpty: "No visible content",
286
+ malformedImage: "This image could not be read. Export it again or choose another file.",
289
287
  },
290
288
  de: {
291
289
  backgroundRun: "Hintergrundlauf",
@@ -309,6 +307,7 @@ const messages = i18n.define({
309
307
  turnErrorTitle: "Die Antwort wurde abgebrochen.",
310
308
  turnErrorReason: ({ code, limitMinutes }) => {
311
309
  if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
310
+ if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
312
311
  if (code === "quota_exhausted")
313
312
  return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
314
313
  if (code === "context_full")
@@ -361,6 +360,7 @@ const messages = i18n.define({
361
360
  stepWritingCode: "Schreibt Code",
362
361
  stepRunningCode: "Führt Code aus",
363
362
  stepCheckingApp: "Prüft die App",
363
+ stepCheckingRun: "Prüft den Lauf",
364
364
  stepCode: "Arbeitet mit Code",
365
365
  stepPlan: "Aktualisiert den Arbeitsplan",
366
366
  stepPdf: "Erstellt ein PDF",
@@ -377,23 +377,18 @@ const messages = i18n.define({
377
377
  stepFailed: "fehlgeschlagen",
378
378
  stepRejected: "abgelehnt",
379
379
  stepWaiting: "wartet",
380
+ checkPassed: "bestanden",
381
+ checkNotPassed: "nicht bestanden",
382
+ checkFindings: ({ count }) => (count === 1 ? "1 Befund" : `${count} Befunde`),
383
+ checkWarnings: ({ count }) => (count === 1 ? "1 Warnung" : `${count} Warnungen`),
384
+ openImage: ({ name }) => `Bild ${name} öffnen`,
380
385
  steered: "Richtung geändert",
381
- reasoning: "Überlegungen",
382
- groupPlan: "Arbeitsplan aktualisiert",
383
- groupCode: "Mit Code gearbeitet",
384
- groupCloud: "Cloud-Integration verwendet",
385
- groupReadFiles: "Dateien gelesen",
386
- groupWriteFiles: "Dateien geschrieben",
387
- groupPdf: "PDFs erstellt",
388
386
  groupLoad: "Werkzeuge und Wissen geladen",
389
- groupWeb: "Im Web recherchiert",
390
- groupMemory: "Gedächtnis genutzt",
391
- groupDelivered: "Ergebnisse bereitgestellt",
392
- groupTools: "Werkzeuge verwendet",
393
387
  groupFailed: ({ count }) => `${count} fehlgeschlagen`,
394
388
  groupRejected: ({ count }) => `${count} abgelehnt`,
395
389
  receiptDone: ({ title }) => `${title} erledigt`,
396
390
  receiptApproved: ({ title }) => `Freigegeben: ${title}`,
391
+ receiptRunning: ({ title }) => `Wird ausgeführt: ${title}`,
397
392
  approvalNeeded: ({ title }) => `Freigabe nötig: ${title}`,
398
393
  answerReady: "Antwort fertig",
399
394
  receiptRejected: ({ title }) => `Abgelehnt: ${title}`,
@@ -404,7 +399,7 @@ const messages = i18n.define({
404
399
  streamAccessDenied: "Du hast keinen Zugriff mehr auf diesen Chat.",
405
400
  streamNotFound: "Dieser Chat ist nicht mehr verfügbar.",
406
401
  assistant: "Assistent",
407
- action: "Aktion",
402
+ approvalRunsAfter: "Wird erst nach deiner Freigabe ausgeführt",
408
403
  approvalRequired: ({ title }) => `Freigabe erforderlich: ${title}`,
409
404
  reject: "Ablehnen",
410
405
  approved: "Freigegeben",
@@ -575,6 +570,7 @@ const messages = i18n.define({
575
570
  compactedCount: ({ count, date }) => `${count === 1 ? "1 Nachricht" : `${count} Nachrichten`} zusammengefasst · ${date}`,
576
571
  compactedHint: "Ab hier sieht das Modell diese Zusammenfassung statt der Nachrichten darüber.",
577
572
  compactedEmpty: "Kein sichtbarer Inhalt",
573
+ malformedImage: "Dieses Bild konnte nicht gelesen werden. Exportiere es erneut oder wähle eine andere Datei.",
578
574
  },
579
575
  },
580
576
  });
@@ -20,7 +20,7 @@ import { type AiChatActions, AiChatActionsProvider, createAssistantMessageAction
20
20
  import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
21
21
  import { aiChatMessages } from "./messages";
22
22
  import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
23
- import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, waitsForUser } from "./turn-layout";
23
+ import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, storedPhase, waitsForUser } from "./turn-layout";
24
24
  import { TurnNavigator } from "./turn-navigator";
25
25
  import { activeTimelineSeq } from "./turn-navigator-utils";
26
26
  import { type AiTurnDuration, type AiTurnSegment, AiTurnView } from "./turn-view";
@@ -102,13 +102,6 @@ const leadingSteerOpener = (blocks: readonly AssistantBlock[]): string | null =>
102
102
  return opener;
103
103
  };
104
104
 
105
- /** History phase of a finished loop, from how its loop ended. */
106
- const storedPhase = (entries: readonly AiStoredMessage[]): AiTurnPhase => {
107
- const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
108
- if (!reason || reason === "stop") return "completed";
109
- return reason === "aborted" ? "stopped" : "failed";
110
- };
111
-
112
105
  /** Responses with a work line, rich places, or a failure notice span the message column so their right edges align. */
113
106
  const isWideLayout = (layout: AiTurnLayout, error?: AiTurnError) =>
114
107
  layout.showWork || layout.results.length > 0 || layout.actions.length > 0 || Boolean(error);
@@ -1,28 +1,35 @@
1
1
  import { Chat, type ChatActivityProps } from "@k2b/ui";
2
- import { createContext, type JSX, splitProps, useContext } from "solid-js";
2
+ import { createContext, createSignal, type JSX, type Signal, splitProps, useContext } from "solid-js";
3
3
 
4
4
  export type AiToolDisclosureState = {
5
5
  get: (blockId: string) => boolean | undefined;
6
6
  set: (blockId: string, open: boolean) => void;
7
7
  };
8
8
 
9
+ /**
10
+ * Disclosure choices by block id. Reactive, so a disclosure that its reader opens renders its body at once, also when
11
+ * nothing else in the turn changes; choices survive a remount and, in session storage, a reload.
12
+ */
9
13
  export const createAiToolDisclosureState = (): AiToolDisclosureState => {
10
- const openByBlockId = new Map<string, boolean>();
14
+ const signals = new Map<string, Signal<boolean | undefined>>();
11
15
  const storageKey = (blockId: string) => `cloud.ai.tool-disclosure:${blockId}`;
16
+ const signal = (blockId: string): Signal<boolean | undefined> => {
17
+ const existing = signals.get(blockId);
18
+ if (existing) return existing;
19
+ let saved: boolean | undefined;
20
+ try {
21
+ if (window.sessionStorage.getItem(storageKey(blockId)) === "open") saved = true;
22
+ } catch {
23
+ /* SSR and disabled storage keep the in-memory state. */
24
+ }
25
+ const created = createSignal(saved);
26
+ signals.set(blockId, created);
27
+ return created;
28
+ };
12
29
  return {
13
- get: (blockId) => {
14
- if (!openByBlockId.has(blockId)) {
15
- try {
16
- const saved = window.sessionStorage.getItem(storageKey(blockId));
17
- if (saved === "open") openByBlockId.set(blockId, true);
18
- } catch {
19
- /* SSR and disabled storage keep the in-memory state. */
20
- }
21
- }
22
- return openByBlockId.get(blockId);
23
- },
30
+ get: (blockId) => signal(blockId)[0](),
24
31
  set: (blockId, open) => {
25
- openByBlockId.set(blockId, open);
32
+ signal(blockId)[1](open);
26
33
  try {
27
34
  if (open) window.sessionStorage.setItem(storageKey(blockId), "open");
28
35
  else window.sessionStorage.removeItem(storageKey(blockId));
@@ -1,10 +1,18 @@
1
+ import { aiSkillFilePathFromMount } from "../file-mount";
1
2
  import type { AiTurnBlock } from "../protocol";
2
3
  import { aiChatMessages } from "./messages";
3
4
 
4
5
  type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
5
6
  type Text = Extract<AiTurnBlock, { kind: "text" }>;
6
7
  export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
7
- export type AiWorkGroup = { kind: "text"; block: Text } | { kind: "steps"; id: string; entries: AiWorkEntry[] };
8
+ /**
9
+ * One row of the expanded work, all at one indent: an intermediate text, a step, or a group that folds housekeeping
10
+ * steps and opens in place.
11
+ */
12
+ export type AiWorkItem =
13
+ | { kind: "text"; block: Text }
14
+ | { kind: "step"; entry: AiWorkEntry }
15
+ | { kind: "housekeeping"; id: string; entries: AiWorkEntry[] };
8
16
 
9
17
  /** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
10
18
  export function isFailedTool(tool: Tool): boolean {
@@ -20,46 +28,65 @@ export function isFailedTool(tool: Tool): boolean {
20
28
  );
21
29
  }
22
30
 
31
+ const HOUSEKEEPING_TOOLS = new Set([
32
+ "load_skill",
33
+ "search_skills",
34
+ "load_tools",
35
+ "search_tools",
36
+ "list_apps",
37
+ "search_help",
38
+ "read_help",
39
+ "search_project",
40
+ "read_project_knowledge",
41
+ ]);
42
+
43
+ /** Preparation the reader rarely needs: loading tools, skills, and knowledge, and reading a loaded skill's files. */
44
+ export const isHousekeepingTool = (tool: Tool): boolean => {
45
+ if (HOUSEKEEPING_TOOLS.has(tool.name)) return true;
46
+ if (tool.name !== "read_file" && tool.name !== "list_files") return false;
47
+ const path = tool.args !== null && typeof tool.args === "object" && "path" in tool.args ? tool.args.path : undefined;
48
+ return typeof path === "string" && aiSkillFilePathFromMount(path.trim()) !== null;
49
+ };
50
+
51
+ const isStep = (entry: AiWorkEntry) => entry.kind === "tool" || entry.kind === "compaction";
52
+
23
53
  /**
24
- * Split folded work into intermediate texts and the step groups between them. Reasoning stays inside its group so it
25
- * does not break up the tools around it. A group is keyed by its first tool, whose id is the same live and in history.
54
+ * The rows of the expanded work, in the original order. A run of at least two housekeeping steps, with the reasoning
55
+ * between them, folds into one group, unless it would hold all but one of the work's steps: then the steps show
56
+ * directly, so the work line never opens to a summary of itself. Entries listed in `shown` already show as rows of
57
+ * their own and never fold into a group, so nothing a reader sees folds away under them. A group is keyed by its first
58
+ * tool, whose id is the same live and in history.
26
59
  */
27
- export function groupWorkBlocks(blocks: readonly AiTurnBlock[]): AiWorkGroup[] {
28
- const groups: AiWorkGroup[] = [];
29
- for (const block of blocks) {
30
- if (block.kind === "steer_message") continue;
31
- if (block.kind === "text") {
32
- groups.push({ kind: "text", block });
33
- continue;
60
+ export function groupWorkBlocks(blocks: readonly AiTurnBlock[], shown: ReadonlySet<string> = new Set()): AiWorkItem[] {
61
+ const entries = blocks.filter((block): block is AiWorkEntry | Text => block.kind !== "steer_message");
62
+ const steps = entries.filter((entry) => entry.kind !== "text" && isStep(entry)).length;
63
+ const items: AiWorkItem[] = [];
64
+ let run: AiWorkEntry[] = [];
65
+ let trailing: AiWorkEntry[] = [];
66
+ const flush = () => {
67
+ if (run.length > 0) {
68
+ const runSteps = run.filter(isStep).length;
69
+ if (runSteps >= 2 && steps - runSteps >= 2) items.push({ kind: "housekeeping", id: run[0]!.id, entries: run });
70
+ else for (const entry of run) items.push({ kind: "step", entry });
71
+ }
72
+ // Reasoning after the last housekeeping step belongs to what comes next.
73
+ for (const entry of trailing) items.push({ kind: "step", entry });
74
+ run = [];
75
+ trailing = [];
76
+ };
77
+ for (const entry of entries) {
78
+ const foldable = !shown.has(entry.id);
79
+ if (foldable && entry.kind === "tool" && isHousekeepingTool(entry)) {
80
+ run.push(...trailing, entry);
81
+ trailing = [];
82
+ } else if (foldable && entry.kind === "thinking" && run.length > 0) trailing.push(entry);
83
+ else {
84
+ flush();
85
+ items.push(entry.kind === "text" ? { kind: "text", block: entry } : { kind: "step", entry });
34
86
  }
35
- const last = groups.at(-1);
36
- if (last?.kind === "steps") last.entries.push(block);
37
- else groups.push({ kind: "steps", id: block.id, entries: [block] });
38
- }
39
- for (const group of groups) {
40
- if (group.kind === "steps") group.id = (group.entries.find((entry) => entry.kind === "tool") ?? group.entries[0]!).id;
41
87
  }
42
- return groups;
43
- }
44
-
45
- /** What a group of tools did, in categories; failures and rejections are counted separately. */
46
- export function summarizeToolGroup(tools: readonly Tool[], locale: string): string {
47
- const t = aiChatMessages(locale);
48
- const categories = tools.map((tool) => {
49
- if (tool.name === "todo_write") return t.groupPlan;
50
- if (tool.name === "present" || tool.name === "code_present" || tool.name === "card" || tool.name === "cloud_card")
51
- return t.groupDelivered;
52
- if (tool.name.startsWith("code_")) return t.groupCode;
53
- if (tool.presentation?.kind === "capability" || tool.name === "read_cloud_resource") return t.groupCloud;
54
- if (["read_file", "list_files", "view_image"].includes(tool.name)) return t.groupReadFiles;
55
- if (tool.name === "write_file") return t.groupWriteFiles;
56
- if (["markdown_to_pdf", "html_to_pdf"].includes(tool.name)) return t.groupPdf;
57
- if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(tool.name)) return t.groupLoad;
58
- if (tool.name.startsWith("web_") || tool.name === "fetch_file") return t.groupWeb;
59
- if (tool.name === "memory") return t.groupMemory;
60
- return t.groupTools;
61
- });
62
- return [...new Set(categories)].join(", ");
88
+ flush();
89
+ return items;
63
90
  }
64
91
 
65
92
  /** "9 steps · 1 failed · 1 rejected" for a group of steps. */
@@ -0,0 +1,157 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ /**
4
+ * People-facing names of Cloud's built-in Assistant tools, keyed by tool name. Step rows, receipts, and approvals show
5
+ * these; a capability tool brings its own translated title instead. A tool test fails when a built-in tool has no entry.
6
+ */
7
+ const toolNames = i18n.define({
8
+ baseLocale: "en",
9
+ messages: {
10
+ en: {
11
+ calculate: "Calculate",
12
+ card: "Card",
13
+ survey: "Survey",
14
+ text_editor: "Text editor",
15
+ local_bash: "Local Bash",
16
+ todo_write: "Working plan",
17
+ memory: "Memory",
18
+ list_files: "List files",
19
+ read_file: "Read file",
20
+ write_file: "Write file",
21
+ fetch_file: "Download file",
22
+ present: "Deliver file",
23
+ markdown_to_pdf: "Markdown to PDF",
24
+ html_to_pdf: "HTML to PDF",
25
+ view_image: "View image",
26
+ transcribe_audio: "Transcribe audio",
27
+ web_search: "Web search",
28
+ web_extract: "Read web page",
29
+ search_tools: "Search tools",
30
+ load_tools: "Load tools",
31
+ list_apps: "List apps",
32
+ search_help: "Search help",
33
+ read_help: "Read help",
34
+ search_project: "Search project",
35
+ read_project_knowledge: "Read project knowledge",
36
+ read_cloud_resource: "Read Cloud item",
37
+ load_skill: "Load skill",
38
+ search_skills: "Search skills",
39
+ code_run: "Run code",
40
+ code_action: "Run app action",
41
+ code_inspect: "Inspect run",
42
+ code_stop: "Stop run",
43
+ code_open: "Open app",
44
+ code_export: "Save run output",
45
+ code_present: "Show app in chat",
46
+ code_check: "App check",
47
+ code_secret: "Enter secret",
48
+ code_database: "App tables",
49
+ code_files: "List stored files",
50
+ code_file_stat: "File details",
51
+ code_file_copy: "Copy file",
52
+ code_database_export: "Export app database",
53
+ code_manage_read: "Read app details",
54
+ code_delete: "Delete app",
55
+ code_database_read: "Read app database",
56
+ code_database_clear: "Clear app data",
57
+ code_database_reset: "Reset app database",
58
+ code_storage_list: "List app storage",
59
+ code_storage_delete: "Delete from app storage",
60
+ code_access_read: "Read app access",
61
+ code_access_change: "Change app access",
62
+ code_actions: "List app actions",
63
+ code_sql: "Query app database",
64
+ code_versions: "List app versions",
65
+ code_list: "Find apps",
66
+ code_read: "Read app source",
67
+ code_history: "App history",
68
+ code_fork: "Copy app",
69
+ code_publish: "Publish app",
70
+ code_unpublish: "Withdraw app",
71
+ code_restore: "Restore app version",
72
+ code_update: "Update app details",
73
+ code_create: "Create app",
74
+ code_write: "Save app files",
75
+ code_remove: "Remove app file",
76
+ },
77
+ de: {
78
+ calculate: "Rechnen",
79
+ card: "Karte",
80
+ survey: "Umfrage",
81
+ text_editor: "Texteditor",
82
+ local_bash: "Lokale Bash",
83
+ todo_write: "Arbeitsplan",
84
+ memory: "Gedächtnis",
85
+ list_files: "Dateien auflisten",
86
+ read_file: "Datei lesen",
87
+ write_file: "Datei schreiben",
88
+ fetch_file: "Datei herunterladen",
89
+ present: "Datei bereitstellen",
90
+ markdown_to_pdf: "Markdown als PDF",
91
+ html_to_pdf: "HTML als PDF",
92
+ view_image: "Bild ansehen",
93
+ transcribe_audio: "Audio transkribieren",
94
+ web_search: "Websuche",
95
+ web_extract: "Webseite lesen",
96
+ search_tools: "Werkzeuge suchen",
97
+ load_tools: "Werkzeuge laden",
98
+ list_apps: "Apps auflisten",
99
+ search_help: "Hilfe durchsuchen",
100
+ read_help: "Hilfe lesen",
101
+ search_project: "Projekt durchsuchen",
102
+ read_project_knowledge: "Projektwissen lesen",
103
+ read_cloud_resource: "Cloud-Eintrag lesen",
104
+ load_skill: "Skill laden",
105
+ search_skills: "Skills suchen",
106
+ code_run: "Code ausführen",
107
+ code_action: "App-Aktion ausführen",
108
+ code_inspect: "Lauf prüfen",
109
+ code_stop: "Lauf beenden",
110
+ code_open: "App öffnen",
111
+ code_export: "Laufergebnis speichern",
112
+ code_present: "App im Chat zeigen",
113
+ code_check: "App-Prüfung",
114
+ code_secret: "Geheimnis eingeben",
115
+ code_database: "App-Tabellen",
116
+ code_files: "Gespeicherte Dateien auflisten",
117
+ code_file_stat: "Dateidetails",
118
+ code_file_copy: "Datei kopieren",
119
+ code_database_export: "App-Datenbank exportieren",
120
+ code_manage_read: "App-Details lesen",
121
+ code_delete: "App löschen",
122
+ code_database_read: "App-Datenbank lesen",
123
+ code_database_clear: "App-Daten leeren",
124
+ code_database_reset: "App-Datenbank zurücksetzen",
125
+ code_storage_list: "App-Speicher auflisten",
126
+ code_storage_delete: "Aus App-Speicher löschen",
127
+ code_access_read: "App-Zugriff lesen",
128
+ code_access_change: "App-Zugriff ändern",
129
+ code_actions: "App-Aktionen auflisten",
130
+ code_sql: "App-Datenbank abfragen",
131
+ code_versions: "App-Versionen auflisten",
132
+ code_list: "Apps suchen",
133
+ code_read: "App-Quelltext lesen",
134
+ code_history: "App-Verlauf",
135
+ code_fork: "App kopieren",
136
+ code_publish: "App veröffentlichen",
137
+ code_unpublish: "App zurückziehen",
138
+ code_restore: "App-Version wiederherstellen",
139
+ code_update: "App-Details ändern",
140
+ code_create: "App erstellen",
141
+ code_write: "App-Dateien speichern",
142
+ code_remove: "App-Datei entfernen",
143
+ },
144
+ },
145
+ });
146
+
147
+ /** Older chats call the visual tools by their `cloud_` names. */
148
+ const ALIASES: Record<string, string> = { cloud_card: "card", cloud_survey: "survey", cloud_text_editor: "text_editor" };
149
+
150
+ /** The localized name of a built-in tool, or undefined for a tool Cloud does not ship. */
151
+ export const aiBuiltinToolName = (name: string, locale: string): string | undefined => {
152
+ const t: Record<string, string> = toolNames.resolve([locale]).t;
153
+ const key = ALIASES[name] ?? name;
154
+ return Object.hasOwn(t, key) ? t[key] : undefined;
155
+ };
156
+
157
+ export const checkAiToolNames = () => toolNames.check();
@@ -1,4 +1,5 @@
1
1
  import { type AiTurnBlock, isRenderableTurnBlock } from "../protocol";
2
+ import type { AiStoredMessage } from "../types";
2
3
  import { hasCapabilityTable } from "./capability-result";
3
4
  import { isCardToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
4
5
  import { isFailedTool } from "./tool-groups";
@@ -78,9 +79,9 @@ const actionState = (block: ToolBlock, phase: AiTurnPhase): AiTurnActionState |
78
79
  if (block.status === "rejected") return "rejected";
79
80
  if (isSurveyToolName(block.name) || isTextEditorToolName(block.name)) return isFailedTool(block) ? null : "interaction";
80
81
  if (block.name === "code_secret") return block.status === "awaiting_client" ? "interaction" : null;
81
- // Effects in Cloud and decided approvals stay visible as receipts, whether or not the model mentions them.
82
+ // Effects in Cloud and approvals, decided or expired, stay visible as receipts, whether or not the model mentions them.
82
83
  const capabilityAction = block.presentation?.kind === "capability" && block.presentation.capabilityKind === "action";
83
- if (!capabilityAction && !block.approved) return null;
84
+ if (!capabilityAction && block.approved === undefined) return null;
84
85
  if (block.status === "running" || block.status === "awaiting_client") return live(phase) ? "running" : "not_run";
85
86
  return isFailedTool(block) ? "failed" : "done";
86
87
  };
@@ -139,3 +140,14 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
139
140
  waitingFor,
140
141
  };
141
142
  }
143
+
144
+ /** The loop ending is authoritative; partial messages also carry their own failed or interrupted ending. */
145
+ export const storedPhase = (entries: readonly Pick<AiStoredMessage, "loopDoneReason" | "stopReason">[]): AiTurnPhase => {
146
+ const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
147
+ if (reason === "aborted") return "stopped";
148
+ if (reason && reason !== "stop") return "failed";
149
+ const stop = entries.findLast((entry) => entry.stopReason)?.stopReason;
150
+ if (stop === "error") return "failed";
151
+ if (stop === "interrupted" || stop === "aborted") return "stopped";
152
+ return "completed";
153
+ };