@k2b/cloud 0.31.0 → 0.33.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 (173) hide show
  1. package/package.json +8 -6
  2. package/src/_internal/define-app.ts +20 -9
  3. package/src/_internal/page-responses.ts +5 -1
  4. package/src/ai/app-skills.ts +1 -7
  5. package/src/ai/audio-tool.ts +2 -1
  6. package/src/ai/browser.ts +109 -0
  7. package/src/ai/capabilities.ts +6 -2
  8. package/src/ai/chart-block.ts +431 -81
  9. package/src/ai/chat/action-sentences.ts +13 -7
  10. package/src/ai/chat/blocks.tsx +11 -7
  11. package/src/ai/chat/builtin-tools.tsx +9 -7
  12. package/src/ai/chat/chart-block.tsx +218 -44
  13. package/src/ai/chat/explorer-block.tsx +276 -0
  14. package/src/ai/chat/file-tools.tsx +40 -1
  15. package/src/ai/chat/message-actions.tsx +6 -0
  16. package/src/ai/chat/message-utils.ts +7 -0
  17. package/src/ai/chat/messages.ts +16 -0
  18. package/src/ai/chat/presentation.tsx +13 -3
  19. package/src/ai/chat/reference-text.tsx +34 -0
  20. package/src/ai/chat/tool-groups.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +14 -0
  22. package/src/ai/chat/turn-view.tsx +17 -7
  23. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  24. package/src/ai/chat/view-block.tsx +417 -0
  25. package/src/ai/chat/visual-tools.tsx +32 -53
  26. package/src/ai/client-tool-results.ts +9 -0
  27. package/src/ai/code-source-contracts.ts +17 -2
  28. package/src/ai/code-source-tools.ts +1 -0
  29. package/src/ai/default-tool-contracts.ts +334 -91
  30. package/src/ai/default-tools.ts +89 -13
  31. package/src/ai/executor.ts +173 -31
  32. package/src/ai/file-error.ts +2 -0
  33. package/src/ai/file-tools.ts +27 -7
  34. package/src/ai/files-store.ts +60 -0
  35. package/src/ai/html-pdf-tool.ts +3 -2
  36. package/src/ai/http.ts +37 -12
  37. package/src/ai/index.ts +90 -0
  38. package/src/ai/local-tool-contracts.ts +244 -0
  39. package/src/ai/malformed-tool-call-repair.ts +144 -0
  40. package/src/ai/markdown-pdf-tool.ts +2 -1
  41. package/src/ai/memories.ts +48 -1
  42. package/src/ai/memory-learning.ts +17 -6
  43. package/src/ai/memory-tool.ts +1 -0
  44. package/src/ai/migrate.ts +15 -0
  45. package/src/ai/openai-api-messages.ts +53 -0
  46. package/src/ai/openai-api-slots.ts +40 -0
  47. package/src/ai/openai-api.ts +383 -0
  48. package/src/ai/protocol.ts +6 -2
  49. package/src/ai/provider.ts +17 -4
  50. package/src/ai/request-context.ts +33 -0
  51. package/src/ai/routes.ts +96 -57
  52. package/src/ai/runtime.ts +48 -12
  53. package/src/ai/settings.ts +5 -0
  54. package/src/ai/skill-catalog.ts +20 -5
  55. package/src/ai/skill-format.ts +17 -7
  56. package/src/ai/skill-tool.ts +15 -5
  57. package/src/ai/skills-routes.ts +20 -6
  58. package/src/ai/skills.ts +26 -7
  59. package/src/ai/store.ts +25 -0
  60. package/src/ai/system-prompt.ts +69 -10
  61. package/src/ai/timeline.ts +3 -1
  62. package/src/ai/tools.ts +3 -0
  63. package/src/ai/types.ts +9 -0
  64. package/src/ai/view-block.ts +941 -0
  65. package/src/ai/vision-tool.ts +2 -1
  66. package/src/api/admin-identity.ts +6 -1
  67. package/src/api/app-approval-actions.ts +36 -0
  68. package/src/api/app-approval.ts +10 -2
  69. package/src/api/approvals-loader.ts +15 -0
  70. package/src/api/approvals.ts +120 -0
  71. package/src/api/index.ts +3 -0
  72. package/src/api/me.ts +12 -3
  73. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  74. package/src/approvals/Approvals.tsx +621 -0
  75. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  76. package/src/approvals/messages.ts +95 -0
  77. package/src/approvals/ui.ts +8 -0
  78. package/src/browser/app-approval.ts +32 -0
  79. package/src/browser/choose-files.tsx +26 -13
  80. package/src/browser/commands.ts +13 -4
  81. package/src/browser/file-provider-error.ts +11 -0
  82. package/src/browser/file-providers.ts +3 -12
  83. package/src/browser/files.tsx +1 -2
  84. package/src/browser/live-connection.ts +2 -3
  85. package/src/browser/navigation-search.ts +4 -1
  86. package/src/browser/provider-list.ts +2 -1
  87. package/src/browser/save-files.tsx +13 -2
  88. package/src/cli/account.ts +21 -2
  89. package/src/cli/commands.ts +4 -2
  90. package/src/contracts/app-approval.ts +120 -0
  91. package/src/contracts/approval-paths.ts +4 -0
  92. package/src/contracts/approvals.ts +81 -0
  93. package/src/contracts/command-path.ts +17 -0
  94. package/src/contracts/commands.ts +5 -15
  95. package/src/contracts/shared.ts +5 -0
  96. package/src/events/live-protocol.ts +2 -1
  97. package/src/events/live-server-message.ts +40 -0
  98. package/src/events/testing.ts +88 -0
  99. package/src/server/index.ts +2 -0
  100. package/src/server/middleware/auth.ts +11 -1
  101. package/src/server/request-timeout.ts +22 -0
  102. package/src/server/services/access.ts +4 -3
  103. package/src/services/app-approval.ts +103 -15
  104. package/src/services/approvals/cursor.ts +15 -0
  105. package/src/services/approvals/device.ts +112 -0
  106. package/src/services/approvals/index.ts +27 -11
  107. package/src/services/approvals/list.ts +120 -0
  108. package/src/services/approvals/migrate.ts +9 -1
  109. package/src/services/identity/models-only-auth.ts +25 -0
  110. package/src/services/notifications/email-frame.ts +16 -3
  111. package/src/services/outbox.ts +13 -6
  112. package/src/services/service-account-credentials.ts +22 -10
  113. package/src/services/settings/core-settings.ts +27 -0
  114. package/src/shared/ai-platform-prompt.ts +29 -3
  115. package/src/shared/email-html.ts +4 -0
  116. package/src/shared/index.ts +6 -1
  117. package/src/shared/markdown/extensions/code.ts +14 -0
  118. package/src/shared/markdown/index.ts +4 -4
  119. package/src/shared/theme.ts +41 -20
  120. package/src/ssr/AnnouncementBanners.tsx +30 -0
  121. package/src/ssr/AppBadge.tsx +4 -4
  122. package/src/ssr/AppLaunchpadPanel.tsx +6 -6
  123. package/src/ssr/CloudShell.island.tsx +91 -0
  124. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  125. package/src/ssr/Layout.tsx +56 -85
  126. package/src/ssr/LayoutHeader.tsx +31 -77
  127. package/src/ssr/LayoutHelp.tsx +2 -57
  128. package/src/ssr/LayoutHelpTrigger.tsx +51 -0
  129. package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
  130. package/src/ssr/LayoutRail.tsx +15 -69
  131. package/src/ssr/MinimalLayout.tsx +2 -3
  132. package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
  133. package/src/ssr/MobileNavigation.tsx +13 -8
  134. package/src/ssr/MobileProfileActions.tsx +10 -7
  135. package/src/ssr/PageTrailRow.tsx +14 -0
  136. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
  137. package/src/ssr/PwaLayout.tsx +0 -2
  138. package/src/ssr/PwaRuntime.island.tsx +1 -1
  139. package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
  140. package/src/ssr/ShellRuntime.island.tsx +170 -0
  141. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  142. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  143. package/src/ssr/announcements-dialog.tsx +66 -0
  144. package/src/ssr/app-badges.ts +50 -6
  145. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  146. package/src/ssr/busy-action.ts +19 -0
  147. package/src/ssr/islands/index.ts +9 -6
  148. package/src/ssr/launchpad-surface.tsx +15 -0
  149. package/src/ssr/layout-help-registry.tsx +60 -0
  150. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  151. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  152. package/src/ssr/preference-controller.ts +30 -21
  153. package/src/ssr/profile-actions.ts +26 -15
  154. package/src/ssr/profile-preferences-messages.ts +8 -4
  155. package/src/ssr/rail-context.ts +8 -12
  156. package/src/ssr/rail-navigation.ts +5 -3
  157. package/src/ssr/runtime.ts +6 -1
  158. package/src/ssr/shell-data.tsx +48 -0
  159. package/src/ssr/timezone-cookie.ts +22 -0
  160. package/src/ssr/workspace-navigation.ts +25 -2
  161. package/src/styles/approvals.css +390 -0
  162. package/src/styles/global.css +22 -7
  163. package/src/styles/utilities-navigation.css +13 -0
  164. package/src/workflows/language/compiler.ts +1 -1
  165. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  166. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  167. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  170. package/src/ssr/PageTrailRow.island.tsx +0 -20
  171. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  172. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  173. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
@@ -0,0 +1,417 @@
1
+ import {
2
+ CopyButton,
3
+ DataTable,
4
+ type DataTableSort,
5
+ DescriptionList,
6
+ StatCell,
7
+ type StatCellAccent,
8
+ StatGrid,
9
+ type StatGridColumns,
10
+ useLocale,
11
+ } from "@k2b/ui";
12
+ import { createMemo, createSignal, createUniqueId, Index, type JSX, Match, onCleanup, onMount, Show, Switch } from "solid-js";
13
+ import type { CloudAiChartRow } from "../chart-block";
14
+ import {
15
+ CLOUD_AI_DETAILS_VIEWPORT_ITEMS,
16
+ CLOUD_AI_VIEW_MAX_BYTES,
17
+ type CloudAiDetailsView,
18
+ type CloudAiExplorerView,
19
+ type CloudAiStatsView,
20
+ type CloudAiTableView,
21
+ type CloudAiView,
22
+ type CloudAiViewSummary,
23
+ cloudAiDetailsRows,
24
+ cloudAiStatsTiles,
25
+ cloudAiTableTsv,
26
+ cloudAiViewSummary,
27
+ cloudAiViewTable,
28
+ isAiViewFile,
29
+ parseCloudAiView,
30
+ sortCloudAiViewRows,
31
+ } from "../view-block";
32
+ import { CloudChartGroupView, CloudChartView } from "./chart-block";
33
+ import { CloudExplorerView } from "./explorer-block";
34
+ import { useAiChatActions } from "./message-actions";
35
+ import { aiChatMessages } from "./messages";
36
+ import { ReferenceText } from "./reference-text";
37
+
38
+ /** Rows a table shows inline, as capability tables do; sorting and Copy data use all of them. */
39
+ const VISIBLE_ROWS = 100;
40
+ /** Rows the viewport shows before it scrolls; it reserves their height from the first frame. */
41
+ const VIEWPORT_ROWS = 8;
42
+ /** A compact row: 12px text on a 16px line, 6px padding above and below, a 1px line. */
43
+ const ROW_REM = 1.8125;
44
+
45
+ /** Loads a view file with the viewer's access and validates it again: a file is untrusted whoever wrote it. */
46
+ async function loadView(url: string, path: string, signal: AbortSignal): Promise<CloudAiView | null> {
47
+ try {
48
+ const response = await fetch(url, { signal });
49
+ if (!response.ok || !isAiViewFile({ path, origin: response.headers.get("X-Cloud-Ai-File-Origin") ?? "" })) return null;
50
+ const blob = await response.blob();
51
+ if (blob.size > CLOUD_AI_VIEW_MAX_BYTES) return null;
52
+ return parseCloudAiView(JSON.parse(await blob.text())) ?? null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** A view's title and subtitle: the loaded file's once it is there, its summary's before. */
59
+ function ViewHeading(props: { id: string; title: string; subtitle?: string; actions?: JSX.Element }) {
60
+ return (
61
+ <header class="flex flex-wrap items-center justify-between gap-2">
62
+ <div class="min-w-0">
63
+ <h3 id={props.id} class="m-0 text-base font-semibold">
64
+ {props.title}
65
+ </h3>
66
+ <Show when={props.subtitle}>{(subtitle) => <span class="text-sm text-secondary">{subtitle()}</span>}</Show>
67
+ </div>
68
+ {props.actions}
69
+ </header>
70
+ );
71
+ }
72
+
73
+ type TableSummary = Extract<CloudAiViewSummary, { view: "table" }>;
74
+ type ChartSummary = Extract<CloudAiViewSummary, { view: "chart" }>;
75
+ type GroupSummary = Extract<CloudAiViewSummary, { view: "group" }>;
76
+ type ExplorerSummary = Extract<CloudAiViewSummary, { view: "explorer" }>;
77
+
78
+ /** The header row, the rows up to the viewport's, and the totals row when there is one. */
79
+ const tableHeight = (summary: TableSummary) =>
80
+ `${(Math.max(1, Math.min(summary.rows, VIEWPORT_ROWS)) + 1 + Number(summary.footer)) * ROW_REM}rem`;
81
+
82
+ /**
83
+ * A table view: title, Copy data and a sortable table in a viewport whose height follows the row count of its
84
+ * `present` summary, so loading, the table and the unavailable state take the same room.
85
+ */
86
+ function CloudTableView(props: { summary: TableSummary; input: CloudAiTableView | null | undefined }) {
87
+ const locale = useLocale();
88
+ const t = () => aiChatMessages(locale());
89
+ const id = `cloud-view-${createUniqueId()}`;
90
+ const table = createMemo(() => (props.input ? cloudAiViewTable(props.input, locale()) : null));
91
+ // The view's own order applies until the reader picks one.
92
+ const [picked, setSort] = createSignal<DataTableSort>();
93
+ const sort = (): DataTableSort | null => {
94
+ const own = props.input?.sort;
95
+ return picked() ?? (own ? { key: own.column, direction: own.direction } : null);
96
+ };
97
+ // cloudAiViewTable returns the view's own order; a picked one sorts again.
98
+ const rows = createMemo(() => {
99
+ const value = table();
100
+ const order = picked();
101
+ return !value ? [] : order ? sortCloudAiViewRows(value.rows, order, locale()) : value.rows;
102
+ });
103
+ const copyText = createMemo(() => {
104
+ const value = table();
105
+ return value ? cloudAiTableTsv({ ...value, rows: rows() }) : "";
106
+ });
107
+ const number = () => new Intl.NumberFormat(locale());
108
+ const refColumns = createMemo(() => new Set(props.input?.columns.flatMap((column) => (column.type === "ref" ? [column.id] : []))));
109
+ return (
110
+ <section class="grid min-w-0 gap-3" aria-labelledby={id}>
111
+ <ViewHeading
112
+ id={id}
113
+ title={props.input?.title ?? props.summary.title}
114
+ subtitle={props.input ? props.input.subtitle : props.summary.subtitle}
115
+ actions={<CopyButton text={copyText()} label={t().viewCopyData} iconOnly size="sm" variant="ghost" disabled={!table()} />}
116
+ />
117
+ <div class="min-w-0" style={{ height: tableHeight(props.summary) }}>
118
+ <Show
119
+ when={table()}
120
+ fallback={
121
+ <div role="status" class="flex h-full items-center justify-center text-center text-xs text-dimmed">
122
+ {props.input === undefined ? t().viewLoading : t().viewUnavailable}
123
+ </div>
124
+ }
125
+ >
126
+ {(value) => (
127
+ <DataTable<CloudAiChartRow>
128
+ rows={rows().slice(0, VISIBLE_ROWS)}
129
+ columns={value().columns.map((column) => ({
130
+ id: column.id,
131
+ header: column.label,
132
+ // A wrapped label would make the header taller than the reserved row; the table scrolls sideways instead.
133
+ headerClass: "whitespace-nowrap",
134
+ value: (row) => row.cells[column.id] ?? "",
135
+ align: column.numeric ? "right" : "left",
136
+ sortable: true,
137
+ }))}
138
+ renderCell={({ row, col, value: text, render }) => {
139
+ // Row keys are row indexes, so a reference cell finds its object in the loaded view.
140
+ const cells = props.input?.rows[Number(row.key)];
141
+ const cell = refColumns().has(col.id) && cells && Object.hasOwn(cells, col.id) ? cells[col.id] : undefined;
142
+ return cell && typeof cell === "object" ? <ReferenceText text={String(text)} reference={cell} /> : render(text);
143
+ }}
144
+ getRowId={(row) => row.key}
145
+ sort={sort()}
146
+ onSortChange={setSort}
147
+ footer={
148
+ value().footer
149
+ ? {
150
+ values: value().footer?.cells,
151
+ renderCell: ({ col, value }) => {
152
+ const text = typeof value === "string" ? value : "";
153
+ const cells = props.input?.footer;
154
+ const cell = refColumns().has(col.id) && cells && Object.hasOwn(cells, col.id) ? cells[col.id] : undefined;
155
+ return cell && typeof cell === "object" ? <ReferenceText text={text} reference={cell} /> : text;
156
+ },
157
+ }
158
+ : undefined
159
+ }
160
+ ariaLabelledBy={id}
161
+ density="compact"
162
+ surface="paper"
163
+ stickyHeader
164
+ class="h-full"
165
+ empty={<span>{t().tableEmpty}</span>}
166
+ />
167
+ )}
168
+ </Show>
169
+ </div>
170
+ <Show when={props.summary.rows > VISIBLE_ROWS}>
171
+ <p class="text-sm text-secondary">
172
+ {t().viewRowsCapped({ shown: number().format(VISIBLE_ROWS), total: number().format(props.summary.rows) })}
173
+ </p>
174
+ </Show>
175
+ </section>
176
+ );
177
+ }
178
+
179
+ type StatsSummary = Extract<CloudAiViewSummary, { view: "stats" }>;
180
+ type DetailsSummary = Extract<CloudAiViewSummary, { view: "details" }>;
181
+
182
+ const DELTA_TONES = { better: "emerald", worse: "red", neutral: "zinc" } as const;
183
+ const DELTA_ICONS = { up: "ti ti-trending-up", down: "ti ti-trending-down", flat: "ti ti-minus" } as const;
184
+ /** One column per tile, up to the grid's six; its ladder narrows them on phones. */
185
+ const STATS_COLUMNS: readonly StatGridColumns[] = [1, 2, 3, 4, 5, 6];
186
+
187
+ /** The host picks the change's color from whether it is better; the model never chooses tones. */
188
+ const deltaAccent = (delta: ReturnType<typeof cloudAiStatsTiles>[number]["delta"]): StatCellAccent | undefined =>
189
+ delta && { tone: DELTA_TONES[delta.tone], icon: DELTA_ICONS[delta.direction], text: delta.text };
190
+
191
+ /** The loading or unavailable message, centered over the room the view reserved. */
192
+ function ViewStatus(props: { loading: boolean }) {
193
+ const locale = useLocale();
194
+ const t = () => aiChatMessages(locale());
195
+ return (
196
+ <div role="status" class="flex h-full items-center justify-center text-center text-xs text-dimmed">
197
+ {props.loading ? t().viewLoading : t().viewUnavailable}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Key figures as stat tiles. The grid's column ladder decides the layout; every tile's height follows from whether it
204
+ * has a sparkline and a support line, which the summary records, so a hidden copy of the same grid reserves the exact
205
+ * room while the file loads or when it is unavailable.
206
+ */
207
+ function CloudStatsView(props: { summary: StatsSummary; input: CloudAiStatsView | null | undefined }) {
208
+ const locale = useLocale();
209
+ const id = `cloud-view-${createUniqueId()}`;
210
+ const tiles = createMemo(() => (props.input ? cloudAiStatsTiles(props.input, locale()) : null));
211
+ const columns = () => STATS_COLUMNS[props.summary.items.length - 1];
212
+ return (
213
+ <section class="grid min-w-0 gap-3" aria-labelledby={id}>
214
+ <ViewHeading
215
+ id={id}
216
+ title={props.input?.title ?? props.summary.title}
217
+ subtitle={props.input ? props.input.subtitle : props.summary.subtitle}
218
+ />
219
+ <Show
220
+ when={tiles()}
221
+ fallback={
222
+ <div class="relative min-w-0">
223
+ <div class="invisible" aria-hidden="true">
224
+ <StatGrid columns={columns()}>
225
+ <Index each={props.summary.items}>
226
+ {(item) => (
227
+ <StatCell
228
+ label={item().label}
229
+ value="0"
230
+ trend={item().sparkline ? [0, 1] : undefined}
231
+ sub={item().sub ? "\u00a0" : undefined}
232
+ accent={item().delta ? { tone: "zinc", icon: DELTA_ICONS.flat, text: "0" } : undefined}
233
+ />
234
+ )}
235
+ </Index>
236
+ </StatGrid>
237
+ </div>
238
+ <div class="absolute inset-0">
239
+ <ViewStatus loading={props.input === undefined} />
240
+ </div>
241
+ </div>
242
+ }
243
+ >
244
+ {(value) => (
245
+ <StatGrid columns={columns()}>
246
+ <Index each={value()}>
247
+ {(tile) => (
248
+ <StatCell
249
+ label={tile().label}
250
+ value={tile().value}
251
+ title={tile().value}
252
+ trend={tile().sparkline}
253
+ sub={tile().sub}
254
+ accent={deltaAccent(tile().delta)}
255
+ />
256
+ )}
257
+ </Index>
258
+ </StatGrid>
259
+ )}
260
+ </Show>
261
+ </section>
262
+ );
263
+ }
264
+
265
+ /** The height of the twelve one-line rows the viewport shows at most before it scrolls. */
266
+ const DETAILS_MAX_REM = CLOUD_AI_DETAILS_VIEWPORT_ITEMS * 2.25;
267
+
268
+ /**
269
+ * Stand-in text of a value's length. Words of four digits wrap about as early as real words, and a digit is a little
270
+ * wider than an average letter, so the stand-in usually takes the lines of the value it stands for or one more: a row
271
+ * may keep a little room below it rather than hide its last line behind a scroll. Five-digit words reserved up to three
272
+ * extra lines in fonts whose digits are much wider than their letters, as WebKit's fallback font.
273
+ */
274
+ const placeholder = (chars: number) =>
275
+ "0000 "
276
+ .repeat(Math.ceil(chars / 5))
277
+ .slice(0, chars)
278
+ .trimEnd();
279
+
280
+ /**
281
+ * One record as label and value rows. A hidden copy of the first twelve fields, with each label and a stand-in of each
282
+ * value's length from the summary, sizes the viewport, up to twelve one-line rows, so a value that wraps on a phone
283
+ * keeps its room in the first frame. More fields, or a value longer than its stand-in, scroll inside the viewport
284
+ * instead of moving the chat.
285
+ */
286
+ function CloudDetailsView(props: { summary: DetailsSummary; input: CloudAiDetailsView | null | undefined }) {
287
+ const locale = useLocale();
288
+ const id = `cloud-view-${createUniqueId()}`;
289
+ const rows = createMemo(() => (props.input ? cloudAiDetailsRows(props.input, locale()) : null));
290
+ return (
291
+ <section class="grid min-w-0 gap-3" aria-labelledby={id}>
292
+ <ViewHeading
293
+ id={id}
294
+ title={props.input?.title ?? props.summary.title}
295
+ subtitle={props.input ? props.input.subtitle : props.summary.subtitle}
296
+ />
297
+ <div class="relative min-w-0 overflow-hidden" style={{ "max-height": `${DETAILS_MAX_REM}rem` }}>
298
+ <div class="invisible" aria-hidden="true">
299
+ <DescriptionList
300
+ layout="rows"
301
+ items={props.summary.fields.map((field) => ({
302
+ term: field.label,
303
+ description: !field.chars ? "—" : field.ref ? <ReferenceText text={placeholder(field.chars)} /> : placeholder(field.chars),
304
+ }))}
305
+ />
306
+ </div>
307
+ <div class="absolute inset-0 overflow-y-auto">
308
+ <Show when={rows()} fallback={<ViewStatus loading={props.input === undefined} />}>
309
+ {(value) => (
310
+ <DescriptionList
311
+ layout="rows"
312
+ items={value().map((row) => ({
313
+ term: row.label,
314
+ description: row.ref ? (
315
+ <ReferenceText text={row.text} reference={row.ref} />
316
+ ) : (
317
+ row.text || <span class="text-dimmed">—</span>
318
+ ),
319
+ }))}
320
+ />
321
+ )}
322
+ </Show>
323
+ </div>
324
+ </div>
325
+ </section>
326
+ );
327
+ }
328
+
329
+ type Heading = { title: string; subtitle?: string };
330
+ const sameHeading = (a: Heading, b: Heading | undefined) => a.title === b?.title && a.subtitle === b.subtitle;
331
+
332
+ /**
333
+ * Whether a loaded file still has everything its summary reserved: kinds, headings, layout and the linked cursor. A
334
+ * changed file shows unavailable instead of moving views or linking charts its summary did not.
335
+ */
336
+ const matches = (view: CloudAiView, summary: CloudAiViewSummary) => {
337
+ if (view.view === "table") return summary.view === "table" && sameHeading(view, summary);
338
+ const loaded = cloudAiViewSummary(view);
339
+ // A record must keep the field count and the labels and value lengths that sized its viewport.
340
+ if (loaded.view === "details")
341
+ return (
342
+ summary.view === "details" &&
343
+ sameHeading(loaded, summary) &&
344
+ loaded.items === summary.items &&
345
+ JSON.stringify(loaded.fields) === JSON.stringify(summary.fields)
346
+ );
347
+ // A stats file must keep every tile's label and parts, which set the tile heights its summary reserved.
348
+ if (loaded.view === "stats")
349
+ return summary.view === "stats" && sameHeading(loaded, summary) && JSON.stringify(loaded.items) === JSON.stringify(summary.items);
350
+ if (loaded.view === "explorer")
351
+ return (
352
+ summary.view === "explorer" &&
353
+ sameHeading(loaded, summary) &&
354
+ loaded.step === summary.step &&
355
+ loaded.filter === summary.filter &&
356
+ loaded.compare === summary.compare &&
357
+ JSON.stringify(loaded.views) === JSON.stringify(summary.views)
358
+ );
359
+ if (loaded.view === "chart") return summary.view === "chart" && loaded.kind === summary.kind && sameHeading(loaded, summary);
360
+ return (
361
+ loaded.view === "group" &&
362
+ summary.view === "group" &&
363
+ sameHeading(loaded, summary) &&
364
+ loaded.linkX === summary.linkX &&
365
+ loaded.layout === summary.layout &&
366
+ loaded.charts.length === summary.charts.length &&
367
+ loaded.charts.every((chart, index) => chart.kind === summary.charts[index]?.kind && sameHeading(chart, summary.charts[index]))
368
+ );
369
+ };
370
+
371
+ /**
372
+ * A presented view file. It takes its full size from the `present` summary in the first frame, then loads the file
373
+ * with the viewer's access; a missing file or one that no longer matches its summary shows the unavailable state in
374
+ * the same frame.
375
+ */
376
+ export function CloudViewBlock(props: { path: string; summary: CloudAiViewSummary }) {
377
+ const actions = useAiChatActions();
378
+ // undefined while loading, null when unavailable.
379
+ const [view, setView] = createSignal<CloudAiView | null | undefined>(undefined);
380
+ onMount(() => {
381
+ const url = actions.fileUrl?.(props.path);
382
+ if (!url) return setView(null);
383
+ const abort = new AbortController();
384
+ onCleanup(() => abort.abort());
385
+ void loadView(url, props.path, abort.signal).then((loaded) => {
386
+ if (!abort.signal.aborted) setView(loaded && matches(loaded, props.summary) ? loaded : null);
387
+ });
388
+ });
389
+ const chartSummary = (): ChartSummary | undefined => (props.summary.view === "chart" ? props.summary : undefined);
390
+ const tableSummary = (): TableSummary | undefined => (props.summary.view === "table" ? props.summary : undefined);
391
+ const groupSummary = (): GroupSummary | undefined => (props.summary.view === "group" ? props.summary : undefined);
392
+ const statsSummary = (): StatsSummary | undefined => (props.summary.view === "stats" ? props.summary : undefined);
393
+ const detailsSummary = (): DetailsSummary | undefined => (props.summary.view === "details" ? props.summary : undefined);
394
+ const explorerSummary = (): ExplorerSummary | undefined => (props.summary.view === "explorer" ? props.summary : undefined);
395
+ function loaded(kind: "chart"): Extract<CloudAiView, { view: "chart" }> | null | undefined;
396
+ function loaded(kind: "table"): CloudAiTableView | null | undefined;
397
+ function loaded(kind: "group"): Extract<CloudAiView, { view: "group" }> | null | undefined;
398
+ function loaded(kind: "stats"): CloudAiStatsView | null | undefined;
399
+ function loaded(kind: "details"): CloudAiDetailsView | null | undefined;
400
+ function loaded(kind: "explorer"): CloudAiExplorerView | null | undefined;
401
+ function loaded(kind: CloudAiView["view"]) {
402
+ const value = view();
403
+ return value && value.view !== kind ? null : value;
404
+ }
405
+ return (
406
+ <div data-view-path={props.path} class="min-w-0">
407
+ <Switch>
408
+ <Match when={chartSummary()}>{(summary) => <CloudChartView summary={summary()} input={loaded("chart")} />}</Match>
409
+ <Match when={tableSummary()}>{(summary) => <CloudTableView summary={summary()} input={loaded("table")} />}</Match>
410
+ <Match when={groupSummary()}>{(summary) => <CloudChartGroupView summary={summary()} group={loaded("group")} />}</Match>
411
+ <Match when={statsSummary()}>{(summary) => <CloudStatsView summary={summary()} input={loaded("stats")} />}</Match>
412
+ <Match when={detailsSummary()}>{(summary) => <CloudDetailsView summary={summary()} input={loaded("details")} />}</Match>
413
+ <Match when={explorerSummary()}>{(summary) => <CloudExplorerView summary={summary()} input={loaded("explorer")} />}</Match>
414
+ </Switch>
415
+ </div>
416
+ );
417
+ }
@@ -1,77 +1,56 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
- import { Button, MarkdownEditor, prompts, Slider, useLocale } from "@k2b/ui";
2
+ import { Button, MarkdownEditor, prompts, Slider, StatCell, type StatCellAccent, StatGrid, useLocale } from "@k2b/ui";
3
3
  import { createSignal, createUniqueId, For, Show } from "solid-js";
4
4
  import { CLOUD_AI_TEXT_EDITOR_FEEDBACK_MAX_CHARS, CLOUD_AI_TEXT_EDITOR_MAX_CHARS } from "../default-tool-contracts";
5
5
  import { isRecord, jsonPreview } from "./message-utils";
6
6
  import { aiChatMessages } from "./messages";
7
7
  import { useAiToolDisclosure } from "./tool-disclosure";
8
8
 
9
- const toneClass = (tone: unknown) => {
10
- if (tone === "blue") return "border-blue-200 bg-blue-50/65 text-blue-950 dark:border-blue-900/70 dark:bg-blue-950/25 dark:text-blue-100";
11
- if (tone === "green")
12
- return "border-emerald-200 bg-emerald-50/65 text-emerald-950 dark:border-emerald-900/70 dark:bg-emerald-950/25 dark:text-emerald-100";
13
- if (tone === "amber")
14
- return "border-amber-200 bg-amber-50/70 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/25 dark:text-amber-100";
15
- if (tone === "red") return "border-red-200 bg-red-50/70 text-red-950 dark:border-red-900/70 dark:bg-red-950/25 dark:text-red-100";
16
- if (tone === "teal") return "border-cyan-200 bg-teal-50/70 text-cyan-950 dark:border-cyan-900/70 dark:bg-cyan-950/25 dark:text-cyan-100";
17
- return "border-zinc-200 bg-white text-primary dark:border-zinc-800 dark:bg-zinc-900";
18
- };
19
-
9
+ /**
10
+ * One `card` tool call as a single stat tile, the same look as key figures in views. Its arguments stay as they
11
+ * were; the card has no better or worse direction, so a trend stays neutral and `tone` no longer colors anything.
12
+ * Nothing compares the tile with a neighbor, so its text wraps: older cards with a long value or caption stay whole.
13
+ */
20
14
  export function CloudCardBlock(props: { args: unknown }) {
21
15
  const card = () => (isRecord(props.args) ? props.args : null);
22
16
  const emoji = () => (typeof card()?.emoji === "string" ? String(card()?.emoji).trim() : "");
23
17
  const title = () => String(card()?.title ?? "Card");
24
18
  const value = () => String(card()?.value ?? "");
25
19
  const caption = () => (typeof card()?.caption === "string" ? String(card()!.caption) : "");
26
- const legacyTrend = () => (isRecord(card()?.trend) ? (card()!.trend as Record<string, unknown>) : null);
20
+ const legacyTrend = () => {
21
+ const trend = card()?.trend;
22
+ return isRecord(trend) ? trend : null;
23
+ };
27
24
  const trendValue = () => (typeof card()?.trendValue === "string" ? String(card()!.trendValue) : String(legacyTrend()?.value ?? ""));
28
25
  const trendLabel = () => (typeof card()?.trendLabel === "string" ? String(card()!.trendLabel) : String(legacyTrend()?.label ?? ""));
29
26
  const trendDirection = () => {
30
27
  const direction = card()?.trendDirection ?? legacyTrend()?.direction;
31
28
  return direction === "up" || direction === "down" || direction === "flat" ? direction : "flat";
32
29
  };
33
- const hasTrend = () => Boolean(trendValue() || trendLabel());
30
+ const accent = (): StatCellAccent | undefined => {
31
+ const text = [trendValue(), trendLabel()].filter(Boolean).join(" ");
32
+ if (!text) return undefined;
33
+ const direction = trendDirection();
34
+ return {
35
+ tone: "zinc",
36
+ icon: direction === "up" ? "ti ti-trending-up" : direction === "down" ? "ti ti-trending-down" : "ti ti-minus",
37
+ text,
38
+ };
39
+ };
34
40
 
35
41
  return (
36
- <div class={`max-w-xl rounded-md border p-2.5 ${toneClass(card()?.tone)}`}>
37
- <Show
38
- when={card()}
39
- fallback={
40
- <pre class="max-h-52 overflow-auto rounded-md bg-zinc-950/5 p-2 text-xs text-primary dark:bg-white/5">
41
- {jsonPreview(props.args)}
42
- </pre>
43
- }
44
- >
45
- <div class="flex items-start gap-2">
46
- <Show when={emoji()}>
47
- <span class="shrink-0 text-2xl leading-7" aria-hidden="true">
48
- {emoji()}
49
- </span>
50
- </Show>
51
- <div class="min-w-0 flex-1">
52
- <p class="text-sm font-semibold">{title()}</p>
53
- <p class="mt-2 text-3xl font-semibold tracking-normal">{value()}</p>
54
- <Show when={hasTrend()}>
55
- <p class="mt-1 inline-flex items-center gap-1 rounded-md bg-white/55 px-1.5 py-0.5 text-xs dark:bg-white/10">
56
- <i
57
- class={`ti ${
58
- trendDirection() === "up" ? "ti-trending-up" : trendDirection() === "down" ? "ti-trending-down" : "ti-minus"
59
- } text-sm`}
60
- aria-hidden="true"
61
- />
62
- <Show when={trendValue()}>{trendValue()}</Show>
63
- <Show when={trendLabel()}>
64
- <span class="opacity-70">{trendLabel()}</span>
65
- </Show>
66
- </p>
67
- </Show>
68
- <Show when={caption()}>
69
- <p class="mt-2 text-xs opacity-70">{caption()}</p>
70
- </Show>
71
- </div>
72
- </div>
73
- </Show>
74
- </div>
42
+ <Show
43
+ when={card()}
44
+ fallback={
45
+ <pre class="max-h-52 overflow-auto rounded-md bg-zinc-950/5 p-2 text-xs text-primary dark:bg-white/5">
46
+ {jsonPreview(props.args)}
47
+ </pre>
48
+ }
49
+ >
50
+ <StatGrid columns={1} class="max-w-sm">
51
+ <StatCell label={emoji() ? `${emoji()} ${title()}` : title()} value={value()} sub={caption() || undefined} accent={accent()} wrap />
52
+ </StatGrid>
53
+ </Show>
75
54
  );
76
55
  }
77
56
 
@@ -0,0 +1,9 @@
1
+ import type { z } from "zod";
2
+ import { createCloudAiLocalBashTool, createCloudAiLocalTools } from "./default-tools";
3
+
4
+ // Two 512 KiB Bash streams, fully JSON-escaped at six bytes per character, plus a 64 KiB envelope.
5
+ export const AI_CLIENT_TOOL_ACTION_MAX_BYTES = 2 * 512 * 1024 * 6 + 64 * 1024;
6
+
7
+ export const aiClientToolOutputSchemas = new Map<string, z.ZodType>(
8
+ [createCloudAiLocalBashTool(), ...createCloudAiLocalTools()].map((tool) => [tool.def.name, tool.def.outputSchema]),
9
+ );
@@ -283,9 +283,24 @@ export const CODE_SOURCE_TOOLS = {
283
283
  input: Id.extend(ArtifactMetadata.shape),
284
284
  },
285
285
  code_create: {
286
+ promptHint:
287
+ "build every app, calculator, converter, timer, form or other tool the person asks for as a saved Studio app. In one turn, load_skill assistant-code-mode and load_tools code_create, code_write, code_check and code_open.",
286
288
  description:
287
- "Create a private reusable App: an index.html interface with optional style.css and app.js, published actions and optional persistence. Starts with a minimal index.html. For one-off analysis, use code_run with code instead. Returns id and entry path; write source with code_write. Optional icon uses the complete class, e.g. ti ti-chart-bar; omit it when unsure. Does not run or share anything.",
288
- input: ArtifactCreate.pick({ title: true, description: true, icon: true }),
289
+ "Create a saved Studio app; it appears in the person's Studio Apps and can be opened, reused and shared. Use it for every app, calculator, converter, timer, form or other interactive tool the person asks for, even when it keeps no data. Pass the complete first version as html (a small app is this one file) and steps (its steps.json), so one call creates it; add style.css, app.js or more files with code_write. Without html it starts with a minimal index.html. Returns id, revision and the lines and length of every file; with html or steps also diagnostics, like code_write. Optional icon uses the complete class, e.g. ti ti-calculator; omit it when unsure. For a one-off analysis use code_run instead. Does not run, check or share anything.",
290
+ input: ArtifactCreate.pick({ title: true, description: true, icon: true }).extend({
291
+ html: z
292
+ .string()
293
+ .max(1024 * 1024)
294
+ .optional()
295
+ .describe(
296
+ 'Complete index.html. A small app fits in this one file: put its script in <script type="module"> and its CSS in <style>. Omit it to start with a minimal page.',
297
+ ),
298
+ steps: z
299
+ .string()
300
+ .max(1024 * 1024)
301
+ .optional()
302
+ .describe("Complete steps.json text for code_check: the main flow, at most 20 steps."),
303
+ }),
289
304
  },
290
305
  // Source text stays top-level because some providers corrupt strings nested in arrays.
291
306
  // Imports carry only references, so they keep an array and one review per batch.
@@ -50,6 +50,7 @@ export function createCodeSourceTool(name: CodeSourceToolName) {
50
50
  inputSchema: definition.input,
51
51
  outputSchema: z.unknown(),
52
52
  approval: "never",
53
+ promptHint: "promptHint" in definition ? definition.promptHint : undefined,
53
54
  }).server(async (input, context) => {
54
55
  if (!context.conversationId) throw new Error("Code tools require an Assistant conversation.");
55
56
  const { actor } = await resolveAiCapabilityActor({