@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
@@ -1,18 +1,20 @@
1
1
  import { aiSkillFilePathFromMount } from "../file-mount";
2
2
  import type { AiTurnBlock } from "../protocol";
3
+ import { isRecord } from "./message-utils";
3
4
  import { aiChatMessages } from "./messages";
4
5
 
5
6
  type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
6
7
  type Text = Extract<AiTurnBlock, { kind: "text" }>;
7
8
  export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
8
9
  /**
9
- * One row of the expanded work, all at one indent: an intermediate text, a step, or a group that folds housekeeping
10
- * steps and opens in place.
10
+ * One row of the expanded work: an intermediate text, a step, or a group that folds housekeeping or repeated calls
11
+ * on one target and opens in place.
11
12
  */
12
13
  export type AiWorkItem =
13
14
  | { kind: "text"; block: Text }
14
15
  | { kind: "step"; entry: AiWorkEntry }
15
- | { kind: "housekeeping"; id: string; entries: AiWorkEntry[] };
16
+ | { kind: "housekeeping"; id: string; entries: AiWorkEntry[] }
17
+ | { kind: "repeat"; id: string; target: string; entries: AiWorkEntry[] };
16
18
 
17
19
  /** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
18
20
  export function isFailedTool(tool: Tool): boolean {
@@ -30,6 +32,7 @@ export function isFailedTool(tool: Tool): boolean {
30
32
 
31
33
  const HOUSEKEEPING_TOOLS = new Set([
32
34
  "load_skill",
35
+ "load_local_skill",
33
36
  "search_skills",
34
37
  "load_tools",
35
38
  "search_tools",
@@ -50,33 +53,85 @@ export const isHousekeepingTool = (tool: Tool): boolean => {
50
53
 
51
54
  const isStep = (entry: AiWorkEntry) => entry.kind === "tool" || entry.kind === "compaction";
52
55
 
56
+ /** The file, address, or query a folded row shows. A call without one never folds with others. */
57
+ const callTarget = (tool: Tool): string | undefined => {
58
+ const args = isRecord(tool.args) ? tool.args : {};
59
+ return [args.path, args.url, args.query].find((value): value is string => typeof value === "string" && value.trim() !== "")?.trim();
60
+ };
61
+
62
+ /** Arguments that page through one call's result, such as read_file's byte slices or an app's cursor and page size. */
63
+ const PAGING_ARGUMENTS = new Set(["offset", "length", "limit", "cursor", "after", "page", "pageSize"]);
64
+
65
+ type Fold = { kind: "housekeeping" } | { kind: "repeat"; name: string; target: string; identity: string };
66
+
67
+ /**
68
+ * What a step folds with: housekeeping with housekeeping, a running or finished call with the same call apart from
69
+ * paging, such as reading one file in parts. Repeated calls need a target; failed, rejected, and waiting calls never repeat.
70
+ */
71
+ const foldOf = (tool: Tool): Fold | null => {
72
+ if (isHousekeepingTool(tool)) return { kind: "housekeeping" };
73
+ const target = callTarget(tool);
74
+ if (!target || isFailedTool(tool) || (tool.status !== "completed" && tool.status !== "running")) return null;
75
+ const args = isRecord(tool.args) ? tool.args : {};
76
+ const identity = JSON.stringify(
77
+ Object.fromEntries(Object.entries(args).filter(([key]) => !PAGING_ARGUMENTS.has(key))),
78
+ (_key: string, value: unknown) =>
79
+ typeof value === "string"
80
+ ? value.trim()
81
+ : isRecord(value)
82
+ ? Object.fromEntries(
83
+ Object.keys(value)
84
+ .sort()
85
+ .map((key) => [key, value[key]]),
86
+ )
87
+ : value,
88
+ );
89
+ return { kind: "repeat", name: tool.name, target, identity };
90
+ };
91
+
92
+ const sameFold = (a: Fold, b: Fold): boolean =>
93
+ a.kind === "housekeeping" ? b.kind === "housekeeping" : b.kind === "repeat" && a.name === b.name && a.identity === b.identity;
94
+
53
95
  /**
54
- * The rows of the expanded work, in the original order. A run of at least two housekeeping steps, with the reasoning
55
- * between them, folds into one group, unless it would hold all but one of the work's steps: then the steps show
56
- * directly, so the work line never opens to a summary of itself. Entries listed in `shown` already show as rows of
57
- * their own and never fold into a group, so nothing a reader sees folds away under them. A group is keyed by its first
58
- * tool, whose id is the same live and in history.
96
+ * The rows of the expanded work, in the original order. A run of at least two steps that fold together, with the
97
+ * reasoning between them, becomes one row: housekeeping, or consecutive instances of the same call apart from paging,
98
+ * such as reading one file in parts. Anything else between them, such as an intermediate text, ends the run. A run that
99
+ * would hold all but one of the work's steps shows its steps directly, so the work line never opens to a summary of
100
+ * itself. Entries listed in `shown` already show as rows of their own and never fold, so nothing a reader sees folds
101
+ * away under them.
102
+ * A folded row is keyed by its first tool, whose id is the same live and in history.
59
103
  */
60
104
  export function groupWorkBlocks(blocks: readonly AiTurnBlock[], shown: ReadonlySet<string> = new Set()): AiWorkItem[] {
61
105
  const entries = blocks.filter((block): block is AiWorkEntry | Text => block.kind !== "steer_message");
62
106
  const steps = entries.filter((entry) => entry.kind !== "text" && isStep(entry)).length;
63
107
  const items: AiWorkItem[] = [];
108
+ let fold: Fold | null = null;
64
109
  let run: AiWorkEntry[] = [];
65
110
  let trailing: AiWorkEntry[] = [];
66
111
  const flush = () => {
67
- if (run.length > 0) {
112
+ if (fold && run.length > 0) {
68
113
  const runSteps = run.filter(isStep).length;
69
- if (runSteps >= 2 && steps - runSteps >= 2) items.push({ kind: "housekeeping", id: run[0]!.id, entries: run });
114
+ const id = run[0]!.id;
115
+ if (runSteps >= 2 && steps - runSteps >= 2)
116
+ items.push(
117
+ fold.kind === "housekeeping"
118
+ ? { kind: "housekeeping", id, entries: run }
119
+ : { kind: "repeat", id, target: fold.target, entries: run },
120
+ );
70
121
  else for (const entry of run) items.push({ kind: "step", entry });
71
122
  }
72
- // Reasoning after the last housekeeping step belongs to what comes next.
123
+ // Reasoning after the last folded step belongs to what comes next.
73
124
  for (const entry of trailing) items.push({ kind: "step", entry });
125
+ fold = null;
74
126
  run = [];
75
127
  trailing = [];
76
128
  };
77
129
  for (const entry of entries) {
78
130
  const foldable = !shown.has(entry.id);
79
- if (foldable && entry.kind === "tool" && isHousekeepingTool(entry)) {
131
+ const next = foldable && entry.kind === "tool" ? foldOf(entry) : null;
132
+ if (next && entry.kind === "tool") {
133
+ if (fold && !sameFold(fold, next)) flush();
134
+ fold = next;
80
135
  run.push(...trailing, entry);
81
136
  trailing = [];
82
137
  } else if (foldable && entry.kind === "thinking" && run.length > 0) trailing.push(entry);
@@ -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",
@@ -72,7 +79,8 @@ const toolNames = i18n.define({
72
79
  code_restore: "Restore app version",
73
80
  code_update: "Update app details",
74
81
  code_create: "Create app",
75
- code_write: "Save app files",
82
+ code_write: "Save app file",
83
+ code_import: "Import app files",
76
84
  code_remove: "Remove app file",
77
85
  },
78
86
  de: {
@@ -82,6 +90,13 @@ const toolNames = i18n.define({
82
90
  survey: "Umfrage",
83
91
  text_editor: "Texteditor",
84
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",
85
100
  todo_write: "Arbeitsplan",
86
101
  memory: "Gedächtnis",
87
102
  list_files: "Dateien auflisten",
@@ -140,7 +155,8 @@ const toolNames = i18n.define({
140
155
  code_restore: "App-Version wiederherstellen",
141
156
  code_update: "App-Details ändern",
142
157
  code_create: "App erstellen",
143
- code_write: "App-Dateien speichern",
158
+ code_write: "App-Datei speichern",
159
+ code_import: "App-Dateien importieren",
144
160
  code_remove: "App-Datei entfernen",
145
161
  },
146
162
  },
@@ -35,6 +35,11 @@ export type AiTurnLayout = {
35
35
  results: AiTurnResult[];
36
36
  /** Place 3: the newest text. Live it is a status; finished it is the final message. */
37
37
  text: TextBlock[];
38
+ /**
39
+ * Place 3 is only a status: the turn runs and a step started after its text. A trailing text may still be the answer
40
+ * and keeps answer typography; it turns quiet when the next step starts, together with the work line's new label.
41
+ */
42
+ textIsStatus: boolean;
38
43
  /** Place 4: effects in Cloud and decisions, open as a card or finished as a receipt. */
39
44
  actions: AiTurnAction[];
40
45
  /** The newest block, which names the current step of a live turn. */
@@ -124,6 +129,8 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
124
129
  }
125
130
  }
126
131
  const visible = new Set(text);
132
+ const lastStep = blocks.findLastIndex((block) => block.kind === "tool" || block.kind === "compaction");
133
+ const shownAt = text.length > 0 ? blocks.indexOf(text.at(-1)!) : -1;
127
134
  const work = blocks.filter((block) => !visible.has(block as TextBlock));
128
135
 
129
136
  const waits = tools.filter(waitsForUser);
@@ -135,6 +142,7 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
135
142
  showWork: steps > 0 || ((options.phase === "stopped" || options.phase === "failed") && work.length > 0),
136
143
  results,
137
144
  text,
145
+ textIsStatus: live(options.phase) && shownAt >= 0 && shownAt < lastStep,
138
146
  actions,
139
147
  current: blocks.at(-1) ?? null,
140
148
  waitingFor,
@@ -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,
@@ -144,7 +145,7 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
144
145
  if (name === "code_present") return withTarget(t.stepPreparingView);
145
146
  if (isCardToolName(name)) return t.stepPreparingCard;
146
147
  if (isChartToolName(name)) return t.stepPreparingChart;
147
- if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
148
+ if (["code_write", "code_import", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
148
149
  return withTarget(t.stepWritingCode);
149
150
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
150
151
  if (name === "code_check") return t.stepCheckingApp;
@@ -236,6 +237,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
236
237
  tone={working() ? "ai" : "neutral"}
237
238
  busy={working()}
238
239
  trailing={trailing()}
240
+ bodyInset={false}
239
241
  renderBody={() => <AiWorkSteps blocks={layout().work} active={working()} />}
240
242
  />
241
243
  );
@@ -275,7 +277,27 @@ function WorkEntryRow(props: { entry: AiWorkEntry; busy: boolean }) {
275
277
  );
276
278
  }
277
279
 
278
- /** Housekeeping folded into one row at step level. It opens in place: its steps follow at the same indent. */
280
+ /** The steps of an opened group, keyed by block id, so a row keeps its element and focus when its block is replaced. */
281
+ function WorkEntries(props: { entries: AiWorkEntry[]; active: boolean }) {
282
+ const entries = createMemo(() => new Map(props.entries.map((entry) => [entry.id, entry])));
283
+ const ids = createMemo(() => [...entries().keys()]);
284
+ return (
285
+ <div class="ai-turn-steps">
286
+ <For each={ids()}>
287
+ {(id, index) => (
288
+ <Show when={entries().get(id)}>
289
+ {(entry) => <WorkEntryRow entry={entry()} busy={props.active && index() === ids().length - 1} />}
290
+ </Show>
291
+ )}
292
+ </For>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ /**
298
+ * Housekeeping folded into one row at step level, with its count at the end like the work line's. It opens in place:
299
+ * its steps follow at the same indent.
300
+ */
279
301
  function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
280
302
  const locale = useLocale();
281
303
  const last = () => props.entries.at(-1)!;
@@ -283,17 +305,33 @@ function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active:
283
305
  <AiToolActivity
284
306
  blockId={`group:${props.id}`}
285
307
  label={aiChatMessages(locale()).groupLoad}
286
- description={countWorkSteps(props.entries, locale())}
308
+ trailing={<span class="ai-turn-work__meta">{countWorkSteps(props.entries, locale())}</span>}
287
309
  icon="ti ti-stack-2"
288
310
  bodyInset={false}
289
311
  busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
290
- renderBody={() => (
291
- <div class="ai-turn-steps">
292
- <For each={props.entries}>
293
- {(entry, index) => <WorkEntryRow entry={entry} busy={props.active && index() === props.entries.length - 1} />}
294
- </For>
295
- </div>
296
- )}
312
+ renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
313
+ />
314
+ );
315
+ }
316
+
317
+ /**
318
+ * Consecutive instances of one call apart from paging, such as reading one file in parts, folded into one row that
319
+ * opens in place. Its count sits at the end like the work line's, so a narrow screen cuts the target short, never the count.
320
+ */
321
+ function RepeatedSteps(props: { id: string; target: string; entries: AiWorkEntry[]; active: boolean }) {
322
+ const locale = useLocale();
323
+ const tools = () => props.entries.filter((entry): entry is ToolBlock => entry.kind === "tool");
324
+ const first = () => tools()[0]!;
325
+ return (
326
+ <AiToolActivity
327
+ blockId={`repeat:${props.id}`}
328
+ label={first().presentation?.title ?? displayToolName(first().name, locale())}
329
+ description={props.target}
330
+ trailing={<span class="ai-turn-work__meta">{aiChatMessages(locale()).repeatCount({ count: tools().length })}</span>}
331
+ icon={aiToolIcon(first().name, first().presentation?.appIcon)}
332
+ bodyInset={false}
333
+ busy={props.active && tools().at(-1)?.status === "running"}
334
+ renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
297
335
  />
298
336
  );
299
337
  }
@@ -311,13 +349,19 @@ function WorkText(props: { text: string }) {
311
349
  }
312
350
 
313
351
  const workItemKey = (item: AiWorkItem): string =>
314
- item.kind === "text" ? `text:${item.block.id}` : item.kind === "step" ? `step:${item.entry.id}` : `group:${item.id}`;
352
+ item.kind === "text"
353
+ ? `text:${item.block.id}`
354
+ : item.kind === "step"
355
+ ? `step:${item.entry.id}`
356
+ : item.kind === "repeat"
357
+ ? `repeat:${item.id}`
358
+ : `group:${item.id}`;
315
359
 
316
360
  /**
317
- * Expanded work, one indent below the work line and one size: intermediate texts as quiet paragraphs, the steps between
318
- * them directly, and housekeeping folded at step level. A row shown while this list is open never folds away under the
319
- * reader, also when reasoning or arguments that would fold it arrive later; the next time it opens, it folds like in
320
- * history.
361
+ * Expanded work, at the work line's indent and one size: intermediate texts as quiet paragraphs, the steps between
362
+ * them directly, and housekeeping or repeated steps folded at step level. A row shown while this list is open never
363
+ * folds away under the reader, also when reasoning or arguments that would fold it arrive later; the next time it
364
+ * opens, it folds like in history.
321
365
  */
322
366
  function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
323
367
  const shown = new Set<string>();
@@ -344,12 +388,19 @@ function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
344
388
  const value = item();
345
389
  return value?.kind === "housekeeping" ? value : undefined;
346
390
  };
391
+ const repeat = () => {
392
+ const value = item();
393
+ return value?.kind === "repeat" ? value : undefined;
394
+ };
347
395
  const active = () => props.active && index() === keys().length - 1;
348
396
  return (
349
397
  <Switch>
350
398
  <Match when={text()}>{(value) => <WorkText text={value()} />}</Match>
351
399
  <Match when={step()}>{(entry) => <WorkEntryRow entry={entry()} busy={active()} />}</Match>
352
400
  <Match when={group()}>{(value) => <HousekeepingGroup id={value().id} entries={value().entries} active={active()} />}</Match>
401
+ <Match when={repeat()}>
402
+ {(value) => <RepeatedSteps id={value().id} target={value().target} entries={value().entries} active={active()} />}
403
+ </Match>
353
404
  </Switch>
354
405
  );
355
406
  }}
@@ -475,10 +526,11 @@ type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
475
526
  */
476
527
  function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
477
528
  const locale = useLocale();
529
+ const timeZone = useReaderTimeZone();
478
530
  const t = () => aiChatMessages(locale());
479
531
  const block = () => props.block;
480
532
  const subject = () => {
481
- const sentence = toolSubject(block(), locale());
533
+ const sentence = toolSubject(block(), locale(), timeZone);
482
534
  if (sentence) return sentence;
483
535
  const title = block().presentation?.title ?? displayToolName(block().name, locale());
484
536
  const target = toolTarget(block());
@@ -486,22 +538,26 @@ function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: bool
486
538
  };
487
539
  const capabilityAction = () => block().presentation?.kind === "capability";
488
540
  const links = () => (props.state === "done" ? resultLinks(block()) : []);
489
- const label = () => {
541
+ const outcomeLabel = () => {
490
542
  const state = props.state;
491
543
  if (state === "running") return t().receiptRunning({ title: subject() });
492
544
  if (state === "done") {
493
545
  // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
494
546
  if (!capabilityAction()) return t().receiptApproved({ title: subject() });
495
- return toolOutcome(block(), "done", locale()) || resultSummary(block()) || t().receiptDone({ title: subject() });
547
+ return toolOutcome(block(), "done", locale(), timeZone) || resultSummary(block()) || t().receiptDone({ title: subject() });
496
548
  }
497
- 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() });
498
550
  if (state === "failed") return t().receiptFailed({ title: subject() });
499
- const notRun = toolOutcome(block(), "notRun", locale());
551
+ const notRun = toolOutcome(block(), "notRun", locale(), timeZone);
500
552
  return notRun
501
553
  ? t().receiptNotRunSentence({ sentence: notRun, stopped: props.stopped })
502
554
  : t().receiptNotRun({ title: subject(), stopped: props.stopped });
503
555
  };
504
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
+ };
505
561
  return (
506
562
  <Chat.Activity
507
563
  class="ai-turn-receipt"
@@ -517,6 +573,7 @@ function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: bool
517
573
 
518
574
  function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; turnId: string; phase: AiTurnPhase }) {
519
575
  const locale = useLocale();
576
+ const timeZone = useReaderTimeZone();
520
577
  let row!: HTMLDivElement;
521
578
  // Set while focus is in this place, including after the focused control disappeared with the decided card.
522
579
  let focusedHere = false;
@@ -547,7 +604,10 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
547
604
  if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
548
605
  announce(
549
606
  aiChatMessages(locale()).approvalNeeded({
550
- 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()),
551
611
  }),
552
612
  );
553
613
  }),
@@ -641,14 +701,18 @@ function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean })
641
701
  }
642
702
 
643
703
  /**
644
- * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
645
- * is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
704
+ * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. Once a step
705
+ * started after it, it reads quietly like the texts in the expanded work; a trailing text may still be the answer and
706
+ * keeps answer typography. A status is not read token by token: screen readers hear each status once it is complete,
707
+ * and that the answer is ready.
646
708
  */
647
709
  function AiTurnText(props: {
648
710
  blocks: Accessor<Extract<AiTurnBlock, { kind: "text" }>[]>;
649
711
  live: Accessor<boolean>;
650
712
  /** The turn has work steps, so its live text is a status rather than the answer itself. */
651
713
  folding: Accessor<boolean>;
714
+ /** A step started after the text, so it can no longer be the answer: it reads quietly. */
715
+ quiet: Accessor<boolean>;
652
716
  /** An earlier segment of a split turn: it stops being live while the turn goes on. */
653
717
  earlier: Accessor<boolean>;
654
718
  }) {
@@ -662,6 +726,7 @@ function AiTurnText(props: {
662
726
  );
663
727
  const html = createMemo(() => markdown.renderSync(source(), { locale: locale() }));
664
728
  const shownId = createMemo(() => props.blocks()[0]?.id);
729
+ const quiet = createMemo(() => props.quiet());
665
730
  const [minHeight, setMinHeight] = createSignal<number | null>(null);
666
731
  let element!: HTMLDivElement;
667
732
  let lastHeight = 0;
@@ -673,14 +738,21 @@ function AiTurnText(props: {
673
738
  observer.observe(element);
674
739
  onCleanup(() => observer.disconnect());
675
740
  });
741
+ /** The quiet text's height in answer typography, measured in place before the browser paints. */
742
+ const answerHeight = () => {
743
+ element.classList.remove("ai-turn__text--status");
744
+ const height = element.offsetHeight;
745
+ element.classList.add("ai-turn__text--status");
746
+ return height;
747
+ };
748
+ // A new status keeps the height of the one before it. A quiet status keeps the height it has as the answer, also when
749
+ // it first shows quietly after a reload, so nothing below moves when it is replaced or becomes the answer again.
676
750
  createEffect(
677
- on(
678
- shownId,
679
- () => {
680
- if (props.live() && lastHeight > 0) setMinHeight((current) => Math.max(current ?? 0, lastHeight));
681
- },
682
- { defer: true },
683
- ),
751
+ on([shownId, quiet], ([id, isQuiet], previous) => {
752
+ if (!props.live()) return;
753
+ const height = Math.max(previous && previous[0] !== id ? lastHeight : 0, isQuiet ? answerHeight() : 0);
754
+ if (height > 0) setMinHeight((current) => Math.max(current ?? 0, height));
755
+ }),
684
756
  );
685
757
  createEffect(() => {
686
758
  if (!props.live()) setMinHeight(null);
@@ -701,6 +773,7 @@ function AiTurnText(props: {
701
773
  <div
702
774
  ref={element}
703
775
  class="ai-turn__text"
776
+ classList={{ "ai-turn__text--status": quiet() }}
704
777
  aria-live={status() ? "off" : undefined}
705
778
  style={minHeight() ? { "min-height": `${minHeight()}px` } : undefined}
706
779
  >
@@ -757,7 +830,13 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
757
830
  </Show>
758
831
  <For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
759
832
  <Show when={layout().text.length > 0}>
760
- <AiTurnText blocks={() => layout().text} live={live} folding={() => layout().steps > 0} earlier={() => props.segment().earlier} />
833
+ <AiTurnText
834
+ blocks={() => layout().text}
835
+ live={live}
836
+ folding={() => layout().steps > 0}
837
+ quiet={() => layout().textIsStatus}
838
+ earlier={() => props.segment().earlier}
839
+ />
761
840
  </Show>
762
841
  <For each={actionIds()}>
763
842
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
@@ -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
+ );