@k2b/cloud 0.30.0 → 0.32.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 (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
@@ -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
 
@@ -1,8 +1,10 @@
1
- import { Chat, ScrollArea } from "@k2b/ui";
1
+ import { Button, Chat, ScrollArea, useLocale } from "@k2b/ui";
2
2
  import { createSignal, For, Show } from "solid-js";
3
3
  import { formatAiFileSize } from "../attachments";
4
4
  import type { AiTurnBlock } from "../protocol";
5
+ import { useAiChatActions } from "./message-actions";
5
6
  import { isRecord } from "./message-utils";
7
+ import { aiChatMessages } from "./messages";
6
8
  import { AiToolActivity } from "./tool-disclosure";
7
9
 
8
10
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -200,3 +202,95 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
200
202
  </Show>
201
203
  );
202
204
  }
205
+
206
+ type WebsiteReceipt = { method: string; url: string };
207
+
208
+ /**
209
+ * Requests a website approval for this chat let through without asking, from a code run or a page read. The call
210
+ * reports them as they go out, so they are there whether it succeeded, failed, or its turn stopped.
211
+ */
212
+ export const websiteReceipts = (block: ToolBlock): WebsiteReceipt[] => block.receipts ?? [];
213
+
214
+ const originOf = (url: string): string => {
215
+ try {
216
+ return new URL(url).origin;
217
+ } catch {
218
+ return url;
219
+ }
220
+ };
221
+
222
+ /**
223
+ * The receipt of every request a website approval let through: one row per website with each full URL, query
224
+ * included, and a revoke action that makes the next request ask again.
225
+ */
226
+ export function WebsiteReceipts(props: { block: ToolBlock }) {
227
+ const actions = useAiChatActions();
228
+ const locale = useLocale();
229
+ const t = () => aiChatMessages(locale());
230
+ const groups = () => {
231
+ const byOrigin = new Map<string, WebsiteReceipt[]>();
232
+ for (const receipt of websiteReceipts(props.block)) {
233
+ const origin = originOf(receipt.url);
234
+ byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), receipt]);
235
+ }
236
+ return [...byOrigin].map(([origin, receipts]) => ({ origin, receipts }));
237
+ };
238
+ return (
239
+ <For each={groups()}>
240
+ {(group) => {
241
+ const [state, setState] = createSignal<"idle" | "busy" | "revoked" | "failed">("idle");
242
+ const host = () => domainOf(group.origin);
243
+ const revoke = async () => {
244
+ if (!actions.onRevokeWebsite || state() === "busy") return;
245
+ setState("busy");
246
+ try {
247
+ await actions.onRevokeWebsite(group.origin);
248
+ setState("revoked");
249
+ } catch {
250
+ setState("failed");
251
+ }
252
+ };
253
+ return (
254
+ <Chat.Activity
255
+ icon="ti ti-world-check"
256
+ label={host()}
257
+ description={
258
+ state() === "revoked"
259
+ ? t().websiteRevoked
260
+ : state() === "failed"
261
+ ? t().revokeFailed
262
+ : t().websiteAllowedForChat({ count: group.receipts.length })
263
+ }
264
+ defaultOpen
265
+ bodyInset={false}
266
+ trailing={
267
+ // The button keeps its place and label once revoked, so nothing moves; the description says it is revoked.
268
+ <Show when={actions.onRevokeWebsite}>
269
+ <Button
270
+ size="xs"
271
+ variant="ghost"
272
+ loading={state() === "busy"}
273
+ disabled={state() === "revoked"}
274
+ aria-label={t().revokeWebsiteFor({ host: host() })}
275
+ onClick={() => void revoke()}
276
+ >
277
+ {t().revoke}
278
+ </Button>
279
+ </Show>
280
+ }
281
+ >
282
+ <ul class="flex w-full min-w-0 flex-col gap-0.5 rounded-md bg-zinc-100/70 px-2 py-1.5 text-xs [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
283
+ <For each={group.receipts}>
284
+ {(receipt) => (
285
+ <li class="min-w-0 break-all font-mono text-[11px] text-secondary">
286
+ <span class="font-semibold text-primary">{receipt.method}</span> {receipt.url}
287
+ </li>
288
+ )}
289
+ </For>
290
+ </ul>
291
+ </Chat.Activity>
292
+ );
293
+ }}
294
+ </For>
295
+ );
296
+ }
@@ -1284,7 +1284,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1284
1284
  conversationId: string,
1285
1285
  turnId: string,
1286
1286
  callId: string,
1287
- action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
1287
+ action: { type: "approval_response"; approved: boolean; remember?: "chat" | "always" } | { type: "tool_result"; result: unknown },
1288
1288
  ) => {
1289
1289
  if (isActiveConversation(conversationId) && runStatus() === "stopping") return false;
1290
1290
  try {
@@ -1325,7 +1325,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1325
1325
  const submitTurnAction = (
1326
1326
  turnId: string,
1327
1327
  callId: string,
1328
- action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
1328
+ action: { type: "approval_response"; approved: boolean; remember?: "chat" | "always" } | { type: "tool_result"; result: unknown },
1329
1329
  ) => {
1330
1330
  const conversationId = activeConversationId();
1331
1331
  if (!conversationId) return Promise.resolve(false);
@@ -1333,7 +1333,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1333
1333
  return submitTurnActionForConversation(conversationId, turnId, callId, action);
1334
1334
  };
1335
1335
 
1336
- const respondToApproval = async (request: { turnId: string; callId: string }, input: { approved: boolean; remember?: "always" }) => {
1336
+ const respondToApproval = async (
1337
+ request: { turnId: string; callId: string },
1338
+ input: { approved: boolean; remember?: "chat" | "always" },
1339
+ ) => {
1337
1340
  const conversationId = activeConversationId();
1338
1341
  if (!conversationId) return false;
1339
1342
  const submitted = await submitTurnAction(request.turnId, request.callId, {