@k2b/cloud 0.31.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 (119) hide show
  1. package/package.json +6 -4
  2. package/src/_internal/define-app.ts +3 -1
  3. package/src/ai/app-skills.ts +1 -7
  4. package/src/ai/audio-tool.ts +2 -1
  5. package/src/ai/browser.ts +109 -0
  6. package/src/ai/capabilities.ts +6 -2
  7. package/src/ai/chart-block.ts +431 -81
  8. package/src/ai/chat/action-sentences.ts +13 -7
  9. package/src/ai/chat/blocks.tsx +11 -7
  10. package/src/ai/chat/builtin-tools.tsx +9 -7
  11. package/src/ai/chat/chart-block.tsx +218 -44
  12. package/src/ai/chat/explorer-block.tsx +276 -0
  13. package/src/ai/chat/file-tools.tsx +40 -1
  14. package/src/ai/chat/message-actions.tsx +6 -0
  15. package/src/ai/chat/message-utils.ts +7 -0
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +13 -3
  18. package/src/ai/chat/reference-text.tsx +34 -0
  19. package/src/ai/chat/tool-groups.ts +1 -0
  20. package/src/ai/chat/tool-names.ts +14 -0
  21. package/src/ai/chat/turn-view.tsx +17 -7
  22. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  23. package/src/ai/chat/view-block.tsx +417 -0
  24. package/src/ai/chat/visual-tools.tsx +32 -53
  25. package/src/ai/client-tool-results.ts +9 -0
  26. package/src/ai/default-tool-contracts.ts +334 -91
  27. package/src/ai/default-tools.ts +87 -11
  28. package/src/ai/executor.ts +173 -31
  29. package/src/ai/file-error.ts +2 -0
  30. package/src/ai/file-tools.ts +27 -7
  31. package/src/ai/files-store.ts +60 -0
  32. package/src/ai/html-pdf-tool.ts +3 -2
  33. package/src/ai/http.ts +37 -12
  34. package/src/ai/index.ts +90 -0
  35. package/src/ai/local-tool-contracts.ts +244 -0
  36. package/src/ai/malformed-tool-call-repair.ts +144 -0
  37. package/src/ai/markdown-pdf-tool.ts +2 -1
  38. package/src/ai/migrate.ts +7 -0
  39. package/src/ai/openai-api-messages.ts +53 -0
  40. package/src/ai/openai-api-slots.ts +40 -0
  41. package/src/ai/openai-api.ts +383 -0
  42. package/src/ai/protocol.ts +6 -2
  43. package/src/ai/provider.ts +16 -4
  44. package/src/ai/request-context.ts +33 -0
  45. package/src/ai/routes.ts +96 -57
  46. package/src/ai/runtime.ts +48 -12
  47. package/src/ai/settings.ts +5 -0
  48. package/src/ai/skill-catalog.ts +20 -5
  49. package/src/ai/skill-format.ts +17 -7
  50. package/src/ai/skill-tool.ts +15 -5
  51. package/src/ai/skills-routes.ts +20 -6
  52. package/src/ai/skills.ts +26 -7
  53. package/src/ai/store.ts +25 -0
  54. package/src/ai/system-prompt.ts +69 -10
  55. package/src/ai/timeline.ts +3 -1
  56. package/src/ai/tools.ts +3 -0
  57. package/src/ai/types.ts +9 -0
  58. package/src/ai/view-block.ts +941 -0
  59. package/src/ai/vision-tool.ts +2 -1
  60. package/src/api/admin-identity.ts +6 -1
  61. package/src/api/app-approval-actions.ts +36 -0
  62. package/src/api/app-approval.ts +10 -2
  63. package/src/api/approvals-loader.ts +15 -0
  64. package/src/api/approvals.ts +120 -0
  65. package/src/api/index.ts +3 -0
  66. package/src/api/me.ts +12 -3
  67. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  68. package/src/approvals/Approvals.tsx +621 -0
  69. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  70. package/src/approvals/messages.ts +95 -0
  71. package/src/approvals/ui.ts +8 -0
  72. package/src/browser/app-approval.ts +32 -0
  73. package/src/browser/choose-files.tsx +26 -13
  74. package/src/browser/commands.ts +13 -4
  75. package/src/browser/file-provider-error.ts +11 -0
  76. package/src/browser/file-providers.ts +3 -12
  77. package/src/browser/files.tsx +1 -2
  78. package/src/browser/live-connection.ts +2 -3
  79. package/src/browser/provider-list.ts +2 -1
  80. package/src/browser/save-files.tsx +13 -2
  81. package/src/cli/account.ts +21 -2
  82. package/src/cli/commands.ts +4 -2
  83. package/src/contracts/app-approval.ts +120 -0
  84. package/src/contracts/approval-paths.ts +4 -0
  85. package/src/contracts/approvals.ts +81 -0
  86. package/src/contracts/command-path.ts +17 -0
  87. package/src/contracts/commands.ts +5 -15
  88. package/src/contracts/shared.ts +5 -0
  89. package/src/events/live-protocol.ts +2 -1
  90. package/src/events/live-server-message.ts +40 -0
  91. package/src/events/testing.ts +88 -0
  92. package/src/server/index.ts +2 -0
  93. package/src/server/middleware/auth.ts +11 -1
  94. package/src/server/request-timeout.ts +22 -0
  95. package/src/server/services/access.ts +4 -3
  96. package/src/services/app-approval.ts +103 -15
  97. package/src/services/approvals/cursor.ts +15 -0
  98. package/src/services/approvals/device.ts +112 -0
  99. package/src/services/approvals/index.ts +27 -11
  100. package/src/services/approvals/list.ts +120 -0
  101. package/src/services/approvals/migrate.ts +9 -1
  102. package/src/services/identity/models-only-auth.ts +25 -0
  103. package/src/services/notifications/email-frame.ts +16 -3
  104. package/src/services/service-account-credentials.ts +22 -10
  105. package/src/services/settings/core-settings.ts +27 -0
  106. package/src/shared/ai-platform-prompt.ts +26 -1
  107. package/src/shared/email-html.ts +4 -0
  108. package/src/shared/index.ts +6 -1
  109. package/src/shared/markdown/extensions/code.ts +14 -0
  110. package/src/shared/markdown/index.ts +4 -4
  111. package/src/ssr/AppBadge.tsx +4 -4
  112. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  113. package/src/ssr/RailApps.island.tsx +2 -2
  114. package/src/ssr/app-badges.ts +50 -6
  115. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  116. package/src/ssr/rail-navigation.ts +5 -3
  117. package/src/ssr/runtime.ts +6 -1
  118. package/src/styles/approvals.css +390 -0
  119. package/src/styles/global.css +5 -2
@@ -0,0 +1,276 @@
1
+ import {
2
+ ChartExplorer,
3
+ type ChartExplorerColumn,
4
+ type ChartExplorerData,
5
+ type ChartExplorerRequest,
6
+ ChartFilterControls,
7
+ type ChartSnapshot,
8
+ createChartExplorer,
9
+ prepareChartSnapshot,
10
+ useLocale,
11
+ } from "@k2b/ui";
12
+ import { createMemo, createSignal, type JSX, Show } from "solid-js";
13
+ import { type CloudAiChartRow, cloudAiChartMarkKey, cloudAiChartReferenceMark, cloudAiChartRenderOptions } from "../chart-block";
14
+ import {
15
+ type CloudAiExplorerRequest,
16
+ type CloudAiExplorerView,
17
+ type CloudAiViewSummary,
18
+ cloudAiExplorerControls,
19
+ cloudAiExplorerState,
20
+ cloudAiViewCellOf,
21
+ cloudAiViewTable,
22
+ } from "../view-block";
23
+ import { aiChatMessages } from "./messages";
24
+ import { ReferenceText } from "./reference-text";
25
+
26
+ type ExplorerSummary = Extract<CloudAiViewSummary, { view: "explorer" }>;
27
+ type Charts = Record<string, ChartExplorerData<CloudAiChartRow>>;
28
+
29
+ /** Rows the table renders inline, as table views do; sorting and Copy data use all of them. */
30
+ const VISIBLE_ROWS = 100;
31
+ /** Every explorer chart kind draws a plot with axes, so all share the chart block's viewport. */
32
+ const HEIGHT = "18rem";
33
+ /** createChartExplorer holds the shown rows as its only entry; views are drawn from them on demand. */
34
+ const TABLE = "table";
35
+
36
+ const emptySnapshot = (kind: ChartSnapshot["kind"]): ChartSnapshot => ({
37
+ kind,
38
+ svg: "",
39
+ width: 480,
40
+ height: 280,
41
+ stretch: true,
42
+ marks: [],
43
+ });
44
+
45
+ /** One state of the explorer: the rows its request shows, as the table, and every view projected from them. */
46
+ function explorerState(view: CloudAiExplorerView, table: CloudAiChartRow[], request: CloudAiExplorerRequest, locale: string) {
47
+ const state = cloudAiExplorerState(view, request, locale);
48
+ const rows = state.rows.map((index) => table[index]!);
49
+ const charts: Charts = { [TABLE]: { chart: emptySnapshot("bar"), rows } };
50
+ return { snapshot: { request: { ...request }, charts }, rows, charts: state.charts };
51
+ }
52
+ type ExplorerState = ReturnType<typeof explorerState>;
53
+
54
+ /**
55
+ * One view of a state, drawn. Drawing is the expensive part, so only a view someone shows is drawn. A view that
56
+ * draws no mark of the shown rows returns undefined, so its frame says why instead of showing an empty plot.
57
+ */
58
+ function drawExplorerView(
59
+ view: CloudAiExplorerView,
60
+ spec: CloudAiExplorerView["views"][number],
61
+ table: CloudAiChartRow[],
62
+ state: ExplorerState,
63
+ locale: string,
64
+ ): ChartExplorerData<CloudAiChartRow> | undefined {
65
+ const chart = state.charts[spec.id];
66
+ if (!chart) return undefined;
67
+ const columnLabel = (id: string) => view.columns.find((column) => column.id === id)?.label ?? id;
68
+ const rowOf = (datum: { seriesIndex?: number; index: number }) => chart.marks[datum.seriesIndex ?? 0]?.[datum.index];
69
+ // The tooltip names what this view encodes; the table and Copy data carry every column.
70
+ const encoded = (seriesIndex: number) => {
71
+ const ys = Array.isArray(spec.y) ? spec.y : [spec.y];
72
+ // Several y columns are one series each; with a series column every series shows the single y.
73
+ const y = spec.kind === "line" || spec.kind === "scatter" ? ys[ys.length > 1 ? seriesIndex % 8 : 0] : spec.y;
74
+ return [
75
+ spec.x,
76
+ ...(y ? [y] : []),
77
+ ...(spec.kind === "heatmap" ? [spec.value] : []),
78
+ ...(spec.kind !== "heatmap" && spec.kind !== "bar" && spec.series ? [spec.series] : []),
79
+ ...(view.controls?.step ? [view.controls.step] : []),
80
+ ];
81
+ };
82
+ try {
83
+ const snapshot = prepareChartSnapshot(cloudAiChartRenderOptions(chart.input, locale, { legend: "outside" }), {
84
+ key: ({ datum }) => cloudAiChartMarkKey(datum),
85
+ rowKey: ({ datum }) => String(rowOf(datum)),
86
+ tooltip: ({ datum }) => {
87
+ const row = table[rowOf(datum) ?? -1];
88
+ const series =
89
+ chart.input.kind === "line" || chart.input.kind === "scatter" ? chart.input.series[datum.seriesIndex ?? 0] : undefined;
90
+ return {
91
+ title: series?.label ?? (row ? row.cells[spec.x] : undefined),
92
+ rows: row
93
+ ? [...new Set(encoded(datum.seriesIndex ?? 0))].map((id) => ({ label: columnLabel(id), value: row.cells[id] ?? "" }))
94
+ : [],
95
+ };
96
+ },
97
+ reference: ({ datum }) => cloudAiChartReferenceMark(chart.input, datum),
98
+ });
99
+ // createChartExplorer rejects a mark without a row; such a view shows why instead of failing the block.
100
+ const shown = new Set(state.rows.map((row) => row.key));
101
+ if (!snapshot.marks.length || !snapshot.marks.every((mark) => shown.has(mark.rowKey))) return undefined;
102
+ return { chart: snapshot, rows: state.rows };
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ const hasControls = (summary: ExplorerSummary) => summary.step || summary.filter;
109
+
110
+ /** The frame of an explorer whose file is loading or unavailable, at the size the loaded explorer takes. */
111
+ function ExplorerPlaceholder(props: { summary: ExplorerSummary; message: JSX.Element }) {
112
+ const locale = useLocale();
113
+ return (
114
+ <ChartExplorer
115
+ title={props.summary.title}
116
+ description={props.summary.subtitle ? <span class="text-sm text-secondary">{props.summary.subtitle}</span> : undefined}
117
+ height={HEIGHT}
118
+ data={{ chart: emptySnapshot(props.summary.views[0]?.kind ?? "bar"), rows: [] }}
119
+ columns={[{ id: "value", label: aiChatMessages(locale()).chartUnavailable, value: () => "" }]}
120
+ charts={props.summary.views}
121
+ chart={props.summary.views[0]?.id}
122
+ controls={
123
+ hasControls(props.summary) ? (
124
+ <ChartFilterControls
125
+ request={{ step: props.summary.step ? "0" : undefined }}
126
+ displayedRequest={{}}
127
+ steps={props.summary.step ? [{ key: "0", label: "" }] : undefined}
128
+ series={props.summary.filter ? [{ key: "0", label: "", color: "var(--stdlib-chart-c1)" }] : undefined}
129
+ compare={props.summary.compare}
130
+ dimensionLabel={"\u00a0"}
131
+ seriesLabel={"\u00a0"}
132
+ disabled
133
+ onRequest={() => {}}
134
+ />
135
+ ) : undefined
136
+ }
137
+ empty={props.message}
138
+ renderDetails={false}
139
+ />
140
+ );
141
+ }
142
+
143
+ /**
144
+ * The loaded explorer. Every filter, step and comparison is computed here from the file's rows; the selection is
145
+ * shared by every view and the table, and all of it is local to this block.
146
+ */
147
+ function LoadedExplorer(props: { summary: ExplorerSummary; input: CloudAiExplorerView }) {
148
+ const locale = useLocale();
149
+ const t = () => aiChatMessages(locale());
150
+ const table = createMemo(() => cloudAiViewTable({ ...props.input, view: "table" }, locale()).rows);
151
+ const controls = createMemo(() => cloudAiExplorerControls(props.input, locale()));
152
+ // createChartExplorer owns the request and the selection over the shown rows; each view is drawn when shown, once
153
+ // per state, so a filter or step change draws one view instead of all of them.
154
+ const states = new WeakMap<Charts, { state: ExplorerState; drawn: Map<string, ChartExplorerData<CloudAiChartRow> | undefined> }>();
155
+ const build = (request: ChartExplorerRequest) => {
156
+ const next = explorerState(props.input, table(), request, locale());
157
+ states.set(next.snapshot.charts, { state: next, drawn: new Map() });
158
+ return next.snapshot;
159
+ };
160
+ const explorer = createChartExplorer<Charts>({ snapshot: () => build(controls().initial), load: build });
161
+ const [chart, setChart] = createSignal(props.input.views[0]!.id);
162
+ const [view, setView] = createSignal<"chart" | "table">("chart");
163
+ const spec = () => props.input.views.find((entry) => entry.id === chart()) ?? props.input.views[0]!;
164
+ const shown = createMemo(() => {
165
+ const charts = explorer.snapshot().charts;
166
+ const entry = states.get(charts)!;
167
+ if (view() === "table") return { data: charts[TABLE]!, failed: false };
168
+ const id = spec().id;
169
+ if (!entry.drawn.has(id)) entry.drawn.set(id, drawExplorerView(props.input, spec(), table(), entry.state, locale()));
170
+ const data = entry.drawn.get(id);
171
+ return data ? { data, failed: false } : { data: { chart: emptySnapshot(spec().kind), rows: [] }, failed: true };
172
+ });
173
+ const legend = () =>
174
+ view() === "chart" && !shown().failed ? states.get(explorer.snapshot().charts)?.state.charts[spec().id]?.legend : undefined;
175
+ // Swatches name chart colors only where the shown view colors its series by the filter column.
176
+ const colored = () => {
177
+ const current = spec();
178
+ return view() === "chart" && (current.kind === "line" || current.kind === "scatter") && current.series === props.input.controls?.filter;
179
+ };
180
+ const columns = createMemo(() =>
181
+ props.input.columns.map(
182
+ (column): ChartExplorerColumn<CloudAiChartRow> => ({
183
+ id: column.id,
184
+ label: column.label ?? column.id,
185
+ value: (row) => row.cells[column.id] ?? "",
186
+ sortValue: (row) => (Object.hasOwn(row.values, column.id) ? (row.values[column.id] ?? null) : null),
187
+ align: column.type === "number" ? "right" : "left",
188
+ ...(column.type === "ref"
189
+ ? {
190
+ render: (row: CloudAiChartRow) => {
191
+ const cells = props.input.rows[Number(row.key)];
192
+ const cell = cells ? cloudAiViewCellOf(cells, column.id) : null;
193
+ const text = row.cells[column.id] ?? "";
194
+ return cell && typeof cell === "object" ? <ReferenceText text={text} reference={cell} /> : text;
195
+ },
196
+ }
197
+ : {}),
198
+ }),
199
+ ),
200
+ );
201
+ const columnLabel = (id: string | undefined) =>
202
+ id === undefined ? undefined : (props.input.columns.find((column) => column.id === id)?.label ?? id);
203
+ return (
204
+ <ChartExplorer<CloudAiChartRow>
205
+ title={props.input.title}
206
+ description={props.input.subtitle ? <span class="text-sm text-secondary">{props.input.subtitle}</span> : undefined}
207
+ height={HEIGHT}
208
+ data={shown().data}
209
+ columns={columns()}
210
+ charts={props.input.views}
211
+ chart={chart()}
212
+ onChartChange={setChart}
213
+ view={view()}
214
+ onViewChange={setView}
215
+ legend={legend()}
216
+ selectedKey={explorer.selectedKey()}
217
+ onSelectedKeyChange={explorer.select}
218
+ tableRows={VISIBLE_ROWS}
219
+ controls={
220
+ hasControls(props.summary) ? (
221
+ <ChartFilterControls
222
+ request={explorer.desired()}
223
+ displayedRequest={explorer.snapshot().request}
224
+ steps={controls().steps.length ? controls().steps : props.summary.step ? [{ key: "0", label: "" }] : undefined}
225
+ series={
226
+ controls().series.length
227
+ ? controls().series.map(({ key, label, color }) => ({
228
+ key,
229
+ label,
230
+ ...(colored() ? { color: `var(--stdlib-chart-c${(color % 8) + 1})` } : {}),
231
+ }))
232
+ : props.summary.filter
233
+ ? [{ key: "0", label: "", color: "var(--stdlib-chart-c1)" }]
234
+ : undefined
235
+ }
236
+ disabled={!props.input.rows.length}
237
+ compare={props.summary.compare}
238
+ dimensionLabel={columnLabel(props.input.controls?.step)}
239
+ seriesLabel={columnLabel(props.input.controls?.filter)}
240
+ onRequest={explorer.setRequest}
241
+ />
242
+ ) : undefined
243
+ }
244
+ empty={
245
+ <span role="status">
246
+ {shown().failed && explorer.snapshot().charts[TABLE]!.rows.length ? t().explorerUndrawable : t().explorerNoRows}
247
+ </span>
248
+ }
249
+ renderDetails={false}
250
+ />
251
+ );
252
+ }
253
+
254
+ /**
255
+ * An explorer view file: one dataset, several charts of it, a shared selection, local filters and the table of the
256
+ * rows shown. It stands at its final size from the `present` summary while the file loads (`input` undefined) and
257
+ * when it is unavailable (`input` null).
258
+ */
259
+ export function CloudExplorerView(props: { summary: ExplorerSummary; input: CloudAiExplorerView | null | undefined }) {
260
+ const locale = useLocale();
261
+ const t = () => aiChatMessages(locale());
262
+ return (
263
+ <Show
264
+ when={props.input}
265
+ keyed
266
+ fallback={
267
+ <ExplorerPlaceholder
268
+ summary={props.summary}
269
+ message={<span role="status">{props.input === undefined ? t().viewLoading : t().viewUnavailable}</span>}
270
+ />
271
+ }
272
+ >
273
+ {(input) => <LoadedExplorer summary={props.summary} input={input} />}
274
+ </Show>
275
+ );
276
+ }
@@ -1,10 +1,12 @@
1
1
  import { fileIcons } from "@k2b/stdlib";
2
2
  import { Chat } from "@k2b/ui";
3
- import { Show } from "solid-js";
3
+ import { createMemo, Show } from "solid-js";
4
4
  import { formatAiFileSize } from "../attachments";
5
5
  import type { AiTurnBlock } from "../protocol";
6
+ import { CloudAiViewSummarySchema, isAiViewFile } from "../view-block";
6
7
  import { useAiChatActions } from "./message-actions";
7
8
  import { aiToolIcon, isRecord } from "./message-utils";
9
+ import { CloudViewBlock } from "./view-block";
8
10
 
9
11
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
10
12
  type PresentResult = { path: string; size: number; mediaType: string };
@@ -20,7 +22,44 @@ const presentResult = (block: ToolBlock): PresentResult | null => {
20
22
  return null;
21
23
  };
22
24
 
25
+ /** The summary `present` returns for a view file; Cloud gives it only for assistant files below /views. */
26
+ const presentedView = (block: ToolBlock) => {
27
+ if (block.status !== "completed" || block.isError || !isRecord(block.result) || typeof block.result.path !== "string") return null;
28
+ const summary = CloudAiViewSummarySchema.safeParse(block.result.view);
29
+ return summary.success && isAiViewFile({ path: block.result.path, origin: "assistant" })
30
+ ? { path: block.result.path, summary: summary.data }
31
+ : null;
32
+ };
33
+
34
+ /**
35
+ * A view's size comes with the finished call's summary. Until then the work line names the step and the result slot
36
+ * stays empty, so no one-line placeholder grows into the chart or table.
37
+ */
38
+ const preparingView = (block: ToolBlock) =>
39
+ block.status === "running" && isRecord(block.args) && typeof block.args.path === "string"
40
+ ? isAiViewFile({ path: block.args.path.trim(), origin: "assistant" })
41
+ : false;
42
+
43
+ /** A presented file: a view file shows as its chart or table, every other file as a chip to open and download. */
23
44
  export function PresentToolBlock(props: { block: ToolBlock }) {
45
+ // Live updates replace the block with an equal result; only a different view loads again.
46
+ const view = createMemo(() => presentedView(props.block), null, { equals: (a, b) => JSON.stringify(a) === JSON.stringify(b) });
47
+ return (
48
+ <Show
49
+ when={view()}
50
+ keyed
51
+ fallback={
52
+ <Show when={!preparingView(props.block)}>
53
+ <PresentedFile block={props.block} />
54
+ </Show>
55
+ }
56
+ >
57
+ {(view) => <CloudViewBlock path={view.path} summary={view.summary} />}
58
+ </Show>
59
+ );
60
+ }
61
+
62
+ function PresentedFile(props: { block: ToolBlock }) {
24
63
  const actions = useAiChatActions();
25
64
  const file = () => presentResult(props.block);
26
65
  const title = () => (isRecord(props.block.args) && typeof props.block.args.title === "string" ? props.block.args.title : null);
@@ -11,6 +11,7 @@ import {
11
11
  useLocale,
12
12
  } from "@k2b/ui";
13
13
  import { createContext, createSignal, For, type JSX, Show, useContext } from "solid-js";
14
+ import type { CloudResourceRef } from "../../contracts/capabilities";
14
15
  import type { AiTurnBlock } from "../protocol";
15
16
  import type { AiMessageFeedback, AiMessageFeedbackReason, AiStoredMessage } from "../types";
16
17
  import {
@@ -62,6 +63,11 @@ export type AiChatActions = {
62
63
  resolveFileLink?: (href: string) => { path: string; href: string } | null;
63
64
  /** Download URL for a conversation VFS file (present blocks, attachment chips). */
64
65
  fileUrl?: (path: string) => string | null;
66
+ /**
67
+ * Open a Cloud object a view references. The host resolves it through the owning app's reader with the viewer's
68
+ * access and confirms before it opens; without this action, references show as text.
69
+ */
70
+ onOpenReference?: (title: string, ref: CloudResourceRef) => void;
65
71
  };
66
72
 
67
73
  const assistantBlocks = (message: Message): Extract<Message, { role: "assistant" }>["content"] =>
@@ -334,6 +334,13 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
334
334
  ["text_editor", "ti ti-edit"],
335
335
  ["cloud_text_editor", "ti ti-edit"],
336
336
  ["local_bash", "ti ti-terminal-2"],
337
+ ["local_list", "ti ti-folder"],
338
+ ["local_read", "ti ti-file-text"],
339
+ ["local_upload", "ti ti-upload"],
340
+ ["local_download", "ti ti-download"],
341
+ ["local_write", "ti ti-file-pencil"],
342
+ ["local_move", "ti ti-arrows-move"],
343
+ ["load_local_skill", "ti ti-book"],
337
344
  ["list_files", "ti ti-file-spark"],
338
345
  ["read_file", "ti ti-file-spark"],
339
346
  ["write_file", "ti ti-file-spark"],
@@ -10,6 +10,13 @@ const messages = i18n.define({
10
10
  tableOpen: "Open",
11
11
  tableEmpty: "No matching rows.",
12
12
  chartUnavailable: "This chart cannot be shown.",
13
+ viewLoading: "Loading…",
14
+ viewUnavailable: "This view is no longer available. Its file was changed or deleted.",
15
+ viewCopyData: "Copy data",
16
+ viewRowsCapped: ({ shown, total }: { shown: string; total: string }) =>
17
+ `Showing ${shown} of ${total} rows. Sort to see others; Copy data copies all of them.`,
18
+ explorerNoRows: "No rows match this selection.",
19
+ explorerUndrawable: "This view cannot be drawn for this selection. The table shows its rows.",
13
20
  tableCapped: "Showing the first 100 returned rows. The full tool result remains available in details.",
14
21
  tableMore: "More rows are available. Ask for the next page or open the result in the application.",
15
22
  assistantTurns: "Assistant turns",
@@ -189,6 +196,7 @@ const messages = i18n.define({
189
196
  toolsLoaded: "Loaded",
190
197
  toolsAlreadyLoaded: "Already loaded",
191
198
  toolUnknown: "Not found",
199
+ toolIsSkill: "A skill, not a tool",
192
200
  toolNotOffered: "Not available here",
193
201
  toolNotAllowed: "Not allowed",
194
202
  toolAppOffline: "App not reachable",
@@ -303,6 +311,13 @@ const messages = i18n.define({
303
311
  tableOpen: "Öffnen",
304
312
  tableEmpty: "Keine passenden Zeilen.",
305
313
  chartUnavailable: "Dieses Diagramm kann nicht angezeigt werden.",
314
+ viewLoading: "Wird geladen …",
315
+ viewUnavailable: "Diese Ansicht ist nicht mehr verfügbar. Ihre Datei wurde geändert oder gelöscht.",
316
+ viewCopyData: "Daten kopieren",
317
+ viewRowsCapped: ({ shown, total }: { shown: string; total: string }) =>
318
+ `${shown} von ${total} Zeilen werden angezeigt. Sortiere, um andere zu sehen; „Daten kopieren“ kopiert alle.`,
319
+ explorerNoRows: "Keine Zeilen passen zu dieser Auswahl.",
320
+ explorerUndrawable: "Diese Ansicht lässt sich für diese Auswahl nicht zeichnen. Die Tabelle zeigt ihre Zeilen.",
306
321
  tableCapped:
307
322
  "Die ersten 100 zurückgegebenen Zeilen werden angezeigt. Das vollständige Werkzeugergebnis bleibt in den Details verfügbar.",
308
323
  tableMore: "Weitere Zeilen sind verfügbar. Frage nach der nächsten Seite oder öffne das Ergebnis in der Anwendung.",
@@ -489,6 +504,7 @@ const messages = i18n.define({
489
504
  toolsLoaded: "Geladen",
490
505
  toolsAlreadyLoaded: "Bereits geladen",
491
506
  toolUnknown: "Nicht gefunden",
507
+ toolIsSkill: "Ein Skill, kein Werkzeug",
492
508
  toolNotOffered: "Hier nicht verfügbar",
493
509
  toolNotAllowed: "Nicht erlaubt",
494
510
  toolAppOffline: "App nicht erreichbar",
@@ -16,6 +16,7 @@ import type { AiActiveTurn } from "../client/projection";
16
16
  import { type AiActiveTurnSegment, type AiTurnBlock, splitActiveTurnBlocks } from "../protocol";
17
17
  import { type AiAssistantTimelineItem, buildAiMessageTimeline } from "../timeline";
18
18
  import type { AiConversationTimelineEntry, AiStoredMessage, AiTurnError } from "../types";
19
+ import { AiReaderTimeZoneProvider } from "./action-sentences";
19
20
  import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
20
21
  import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
21
22
  import { aiChatMessages } from "./messages";
@@ -112,7 +113,7 @@ const isEmptyLayout = (layout: AiTurnLayout) => !isWideLayout(layout) && layout.
112
113
  * Turn views live in their own roots, keyed by timeline id. A changed segment updates its view instead of
113
114
  * replacing it, so host state inside a turn, such as a running Studio session, survives every update.
114
115
  */
115
- const createTurnViews = (disclosureState: AiToolDisclosureState) => {
116
+ const createTurnViews = (disclosureState: AiToolDisclosureState, timeZone: string | undefined) => {
116
117
  const owner = getOwner();
117
118
  const views = new Map<string, { set: Setter<AiTurnSegment>; content: JSX.Element; dispose: () => void }>();
118
119
  onCleanup(() => {
@@ -128,7 +129,11 @@ const createTurnViews = (disclosureState: AiToolDisclosureState) => {
128
129
  }
129
130
  return createRoot((dispose) => {
130
131
  const [current, set] = createSignal(segment);
131
- const content = <AiTurnView segment={current} disclosureState={disclosureState} />;
132
+ const content = (
133
+ <AiReaderTimeZoneProvider value={timeZone}>
134
+ <AiTurnView segment={current} disclosureState={disclosureState} />
135
+ </AiReaderTimeZoneProvider>
136
+ );
132
137
  views.set(segment.id, { set, content, dispose });
133
138
  return content;
134
139
  }, owner);
@@ -472,6 +477,11 @@ export type AiChatTimelineSource = {
472
477
  activeTurn: Accessor<AiActiveTurn | null>;
473
478
  /** The conversation stream lost its connection and reconnects, such as `streamStatus() === "reconnecting"`. */
474
479
  reconnecting?: Accessor<boolean>;
480
+ /**
481
+ * The reader's IANA time zone, such as the page's `getTimeZone(c)`, so times in server-rendered history match the
482
+ * browser. Defaults to the browser's own zone, and to UTC outside a browser.
483
+ */
484
+ timeZone?: string;
475
485
  };
476
486
 
477
487
  /**
@@ -483,7 +493,7 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
483
493
  const actions = useAiChatActions();
484
494
  const locale = useLocale();
485
495
  const disclosureState = createAiToolDisclosureState();
486
- const views = createTurnViews(disclosureState);
496
+ const views = createTurnViews(disclosureState, source.timeZone);
487
497
  // While the stream reconnects or a model call waits for its retry, the live turn says so and its clock stands.
488
498
  const reconnecting = createMemo(() => source.activeTurn()?.providerRetry === true || source.reconnecting?.() === true);
489
499
  const clock = createActiveTurnClock(source, reconnecting);
@@ -0,0 +1,34 @@
1
+ import { markdownReferenceIcon } from "@k2b/ui";
2
+ import { Show } from "solid-js";
3
+ import type { CloudAiViewReference } from "../view-block";
4
+ import { useAiChatActions } from "./message-actions";
5
+
6
+ /**
7
+ * A Cloud object a view names. It carries no URL: the host resolves it on click with the viewer's access and confirms
8
+ * before it opens. It looks like a reference to Cloud content in Markdown, a calm pill, and stays text without a host.
9
+ * A Cloud reference names an object of the owning app, not a kind the chat knows, such as a task or an event, so the
10
+ * pill keeps the neutral page icon that relative links in chat Markdown also show. Without a reference it only takes
11
+ * the pill's room, for a hidden copy that reserves it.
12
+ */
13
+ export function ReferenceText(props: { text: string; reference?: CloudAiViewReference }) {
14
+ const actions = useAiChatActions();
15
+ return (
16
+ <Show when={actions.onOpenReference} fallback={props.text}>
17
+ {(open) => (
18
+ <button
19
+ type="button"
20
+ // The pill's fill reaches past the text without making a compact table row taller than the one reserved.
21
+ class="k2b-reference -my-[0.125em] cursor-pointer text-left"
22
+ data-reference="page"
23
+ onClick={() => {
24
+ const reference = props.reference;
25
+ if (reference) open()(props.text, { type: reference.type, id: reference.id });
26
+ }}
27
+ >
28
+ <i class={`k2b-reference__icon ti ${markdownReferenceIcon("page")}`} aria-hidden="true" />
29
+ {props.text}
30
+ </button>
31
+ )}
32
+ </Show>
33
+ );
34
+ }
@@ -32,6 +32,7 @@ export function isFailedTool(tool: Tool): boolean {
32
32
 
33
33
  const HOUSEKEEPING_TOOLS = new Set([
34
34
  "load_skill",
35
+ "load_local_skill",
35
36
  "search_skills",
36
37
  "load_tools",
37
38
  "search_tools",
@@ -14,6 +14,13 @@ const toolNames = i18n.define({
14
14
  survey: "Survey",
15
15
  text_editor: "Text editor",
16
16
  local_bash: "Local Bash",
17
+ local_list: "List local files",
18
+ local_read: "Read local file",
19
+ local_upload: "Upload local files",
20
+ local_download: "Save to local folder",
21
+ local_write: "Write local file",
22
+ local_move: "Move local files",
23
+ load_local_skill: "Load local Skill",
17
24
  todo_write: "Working plan",
18
25
  memory: "Memory",
19
26
  list_files: "List files",
@@ -83,6 +90,13 @@ const toolNames = i18n.define({
83
90
  survey: "Umfrage",
84
91
  text_editor: "Texteditor",
85
92
  local_bash: "Lokale Bash",
93
+ local_list: "Lokale Dateien auflisten",
94
+ local_read: "Lokale Datei lesen",
95
+ local_upload: "Lokale Dateien hochladen",
96
+ local_download: "In lokalen Ordner speichern",
97
+ local_write: "Lokale Datei schreiben",
98
+ local_move: "Lokale Dateien verschieben",
99
+ load_local_skill: "Lokalen Skill laden",
86
100
  todo_write: "Arbeitsplan",
87
101
  memory: "Gedächtnis",
88
102
  list_files: "Dateien auflisten",
@@ -1,3 +1,4 @@
1
+ import { dates } from "@k2b/stdlib";
1
2
  import { announce, Button, ButtonLink, Chat, NoticeCard, useLocale } from "@k2b/ui";
2
3
  import {
3
4
  type Accessor,
@@ -17,7 +18,7 @@ import {
17
18
  import { markdown } from "../../shared";
18
19
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
19
20
  import type { AiTurnError } from "../types";
20
- import { toolOutcome, toolSubject } from "./action-sentences";
21
+ import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
21
22
  import {
22
23
  type AiApprovalDecision,
23
24
  ApprovalBlockView,
@@ -525,10 +526,11 @@ type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
525
526
  */
526
527
  function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
527
528
  const locale = useLocale();
529
+ const timeZone = useReaderTimeZone();
528
530
  const t = () => aiChatMessages(locale());
529
531
  const block = () => props.block;
530
532
  const subject = () => {
531
- const sentence = toolSubject(block(), locale());
533
+ const sentence = toolSubject(block(), locale(), timeZone);
532
534
  if (sentence) return sentence;
533
535
  const title = block().presentation?.title ?? displayToolName(block().name, locale());
534
536
  const target = toolTarget(block());
@@ -536,22 +538,26 @@ function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: bool
536
538
  };
537
539
  const capabilityAction = () => block().presentation?.kind === "capability";
538
540
  const links = () => (props.state === "done" ? resultLinks(block()) : []);
539
- const label = () => {
541
+ const outcomeLabel = () => {
540
542
  const state = props.state;
541
543
  if (state === "running") return t().receiptRunning({ title: subject() });
542
544
  if (state === "done") {
543
545
  // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
544
546
  if (!capabilityAction()) return t().receiptApproved({ title: subject() });
545
- return toolOutcome(block(), "done", locale()) || resultSummary(block()) || t().receiptDone({ title: subject() });
547
+ return toolOutcome(block(), "done", locale(), timeZone) || resultSummary(block()) || t().receiptDone({ title: subject() });
546
548
  }
547
- if (state === "rejected") return toolOutcome(block(), "rejected", locale()) ?? t().receiptRejected({ title: subject() });
549
+ if (state === "rejected") return toolOutcome(block(), "rejected", locale(), timeZone) ?? t().receiptRejected({ title: subject() });
548
550
  if (state === "failed") return t().receiptFailed({ title: subject() });
549
- const notRun = toolOutcome(block(), "notRun", locale());
551
+ const notRun = toolOutcome(block(), "notRun", locale(), timeZone);
550
552
  return notRun
551
553
  ? t().receiptNotRunSentence({ sentence: notRun, stopped: props.stopped })
552
554
  : t().receiptNotRun({ title: subject(), stopped: props.stopped });
553
555
  };
554
556
  const declined = () => props.state === "rejected" || props.state === "not_run";
557
+ const label = () => {
558
+ const at = block().at;
559
+ return at && props.state !== "running" ? `${outcomeLabel()} · ${dates.formatTime(at, { locale: locale(), timeZone })}` : outcomeLabel();
560
+ };
555
561
  return (
556
562
  <Chat.Activity
557
563
  class="ai-turn-receipt"
@@ -567,6 +573,7 @@ function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: bool
567
573
 
568
574
  function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; turnId: string; phase: AiTurnPhase }) {
569
575
  const locale = useLocale();
576
+ const timeZone = useReaderTimeZone();
570
577
  let row!: HTMLDivElement;
571
578
  // Set while focus is in this place, including after the focused control disappeared with the decided card.
572
579
  let focusedHere = false;
@@ -597,7 +604,10 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
597
604
  if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
598
605
  announce(
599
606
  aiChatMessages(locale()).approvalNeeded({
600
- title: toolSubject(current.block, locale()) ?? current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
607
+ title:
608
+ toolSubject(current.block, locale(), timeZone) ??
609
+ current.block.presentation?.title ??
610
+ displayToolName(current.block.name, locale()),
601
611
  }),
602
612
  );
603
613
  }),
@@ -0,0 +1,35 @@
1
+ import { createSignal, For } from "solid-js";
2
+ import { render } from "solid-js/web";
3
+ import type { CloudResourceRef } from "../../contracts/capabilities";
4
+ import type { AiTurnBlock } from "../protocol";
5
+ import { PresentToolBlock } from "./file-tools";
6
+ import { AiChatActionsProvider } from "./presentation";
7
+ import { CloudCardBlock } from "./visual-tools";
8
+
9
+ /**
10
+ * Presented files for a browser test, in a chat-wide column. `window.show(blocks)` replaces the shown `present`
11
+ * blocks and `window.showCards(args)` the shown `card` calls. Chat files load from `/files?path=`; `window.opened`
12
+ * records the references a reader opened.
13
+ */
14
+ const [blocks, setBlocks] = createSignal<Extract<AiTurnBlock, { kind: "tool" }>[]>([]);
15
+ const [cards, setCards] = createSignal<unknown[]>([]);
16
+ const opened: { title: string; ref: CloudResourceRef }[] = [];
17
+ Object.assign(window, { show: setBlocks, showCards: setCards, opened });
18
+
19
+ const root = document.createElement("main");
20
+ root.style.cssText = "display: flex; flex-direction: column; gap: 1.5rem; max-width: 48rem; margin: 0 auto; padding: 1rem;";
21
+ document.body.append(root);
22
+ render(
23
+ () => (
24
+ <AiChatActionsProvider
25
+ actions={{
26
+ fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
27
+ onOpenReference: (title, ref) => opened.push({ title, ref }),
28
+ }}
29
+ >
30
+ <For each={blocks()}>{(block) => <PresentToolBlock block={block} />}</For>
31
+ <For each={cards()}>{(args) => <CloudCardBlock args={args} />}</For>
32
+ </AiChatActionsProvider>
33
+ ),
34
+ root,
35
+ );