@k2b/cloud 0.30.0 → 0.31.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 (76) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +14 -4
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/approval-routes.ts +40 -4
  8. package/src/ai/approvals.ts +68 -10
  9. package/src/ai/chat/blocks.tsx +53 -32
  10. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  11. package/src/ai/chat/message-actions.tsx +3 -1
  12. package/src/ai/chat/messages.ts +18 -0
  13. package/src/ai/chat/tool-groups.ts +66 -12
  14. package/src/ai/chat/tool-names.ts +4 -2
  15. package/src/ai/chat/turn-layout.ts +8 -0
  16. package/src/ai/chat/turn-view.tsx +94 -25
  17. package/src/ai/chat/web-tools.tsx +95 -1
  18. package/src/ai/client/controller.ts +6 -3
  19. package/src/ai/code-runtime-tools.ts +50 -17
  20. package/src/ai/code-source-contracts.ts +25 -21
  21. package/src/ai/code-source-tools.ts +3 -1
  22. package/src/ai/executor.ts +170 -14
  23. package/src/ai/fetch-file-tool.ts +41 -4
  24. package/src/ai/firecrawl-tools.ts +87 -4
  25. package/src/ai/index.ts +8 -0
  26. package/src/ai/migrate.ts +113 -2
  27. package/src/ai/protocol.ts +22 -1
  28. package/src/ai/routes.ts +11 -0
  29. package/src/ai/runtime.ts +46 -7
  30. package/src/ai/store.ts +66 -1
  31. package/src/ai/tool-audit.ts +11 -1
  32. package/src/ai/tools.ts +91 -3
  33. package/src/ai/types.ts +39 -0
  34. package/src/ai/web-provenance.ts +103 -0
  35. package/src/api/capabilities.ts +3 -2
  36. package/src/api/widgets.ts +36 -5
  37. package/src/browser/CloudResourceSearch.tsx +1 -1
  38. package/src/browser/command-shortcuts.ts +21 -6
  39. package/src/browser/spotlight-position.ts +4 -1
  40. package/src/browser/widgets.ts +7 -5
  41. package/src/capabilities/executions.ts +3 -0
  42. package/src/cli/commands.ts +20 -0
  43. package/src/cli/index.ts +16 -0
  44. package/src/cli/plugin.ts +30 -0
  45. package/src/contracts/app.ts +34 -2
  46. package/src/contracts/registry.ts +9 -1
  47. package/src/contracts/widgets.ts +47 -2
  48. package/src/desktop/solid.tsx +6 -7
  49. package/src/server/index.ts +1 -0
  50. package/src/server/widget-request.ts +27 -0
  51. package/src/services/approvals/index.ts +225 -0
  52. package/src/services/approvals/migrate.ts +33 -0
  53. package/src/shared/app-presentation.ts +11 -0
  54. package/src/shared/email-html.ts +43 -8
  55. package/src/ssr/AdminLayout.tsx +4 -7
  56. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  57. package/src/ssr/Layout.tsx +38 -14
  58. package/src/ssr/LayoutFooter.tsx +1 -1
  59. package/src/ssr/LayoutHeader.tsx +17 -20
  60. package/src/ssr/LayoutHelp.tsx +1 -1
  61. package/src/ssr/LayoutRail.tsx +1 -2
  62. package/src/ssr/PageTrail.tsx +23 -0
  63. package/src/ssr/PageTrailRow.island.tsx +20 -0
  64. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  65. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  66. package/src/ssr/islands/index.ts +1 -0
  67. package/src/ssr/layout-runtime.ts +60 -0
  68. package/src/ssr/mobile-menu-history.ts +1 -1
  69. package/src/ssr/page-trail-runtime.ts +313 -0
  70. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  71. package/src/ssr/page-trail.ts +59 -0
  72. package/src/ssr/platform-messages.ts +4 -0
  73. package/src/styles/effects.css +21 -4
  74. package/src/styles/global.css +35 -2
  75. package/src/styles/utilities-layout.css +24 -1
  76. package/src/styles/utilities-navigation.css +6 -18
@@ -34,7 +34,7 @@ import { AssistantMarkdownBlock } from "./primitives";
34
34
  import { AiToolActivity } from "./tool-disclosure";
35
35
  import { isFailedTool } from "./tool-groups";
36
36
  import { CloudCardBlock, CloudSurveyBlock, CloudSurveyResultBlock, CloudTextEditorBlock, CloudTextEditorResultBlock } from "./visual-tools";
37
- import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock } from "./web-tools";
37
+ import { FetchFileToolBlock, WebExtractToolBlock, WebSearchToolBlock, WebsiteReceipts, websiteReceipts } from "./web-tools";
38
38
 
39
39
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
40
40
  type ReviewDetail = NonNullable<CapabilityActionReview["details"]>[number];
@@ -205,7 +205,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
205
205
  const [detailsOpen, setDetailsOpen] = createSignal(false);
206
206
  // The call this card answered: a later approval for the same block may already have arrived when the server accepts.
207
207
  let decision: AiApprovalDecision | null = null;
208
- const approval = mutation.create<void, { approved: boolean; remember?: "always" }>({
208
+ const approval = mutation.create<void, { approved: boolean; remember?: "chat" | "always" }>({
209
209
  mutation: async (input) => {
210
210
  if (!actions.onApproval) throw new Error("Approval is unavailable.");
211
211
  await actions.onApproval(request(), input);
@@ -215,7 +215,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
215
215
  if (decision) props.onDecided?.(decision);
216
216
  },
217
217
  });
218
- const submit = (input: { approved: boolean; remember?: "always" }) => {
218
+ const submit = (input: { approved: boolean; remember?: "chat" | "always" }) => {
219
219
  if (actionDisabled() || approval.loading()) return;
220
220
  decision = { callId: props.block.callId, approved: input.approved };
221
221
  void approval.mutate(input);
@@ -411,6 +411,16 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
411
411
  icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
412
412
  action: () => setDetailsOpen((open) => !open),
413
413
  },
414
+ // The narrower chat reach comes first: it ends with this chat.
415
+ ...(props.block.approval?.allowChat
416
+ ? [
417
+ {
418
+ label: props.block.approval.website ? t().allowWebsiteForChat : t().chatApprove,
419
+ icon: props.block.approval.website ? "ti ti-world-check" : "ti ti-message-check",
420
+ action: () => submit({ approved: true, remember: "chat" }),
421
+ },
422
+ ]
423
+ : []),
414
424
  ...(props.block.approval?.allowAlways
415
425
  ? [
416
426
  {
@@ -725,6 +735,9 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
725
735
  <Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
726
736
  </Match>
727
737
  </Switch>
738
+ <Show when={websiteReceipts(props.block).length > 0}>
739
+ <WebsiteReceipts block={props.block} />
740
+ </Show>
728
741
  <Show when={hasCapabilityTable(props.block)}>
729
742
  <CapabilityTablePreview
730
743
  result={props.block.result}
@@ -853,35 +866,43 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
853
866
  return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
854
867
  };
855
868
  return (
856
- <AiToolActivity
857
- blockId={props.block.id}
858
- label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
859
- description={[
860
- props.block.status === "running" ? (props.block.progress ?? "") : "",
861
- typeof detail() === "string" ? String(detail()) : "",
862
- state(),
863
- outcome(),
864
- ]
865
- .filter(Boolean)
866
- .join(" · ")}
867
- icon={icon()}
868
- busy={props.busy && props.block.status === "running"}
869
- bodyInset={false}
870
- renderBody={() => (
871
- <div class="flex min-w-0 flex-col gap-2">
872
- <div
873
- class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
874
- tabIndex={0}
875
- role="region"
876
- aria-label={t().toolInputOutput}
877
- >
878
- <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
879
- <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
869
+ <>
870
+ <AiToolActivity
871
+ blockId={props.block.id}
872
+ label={sentence() ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
873
+ description={[
874
+ props.block.status === "running" ? (props.block.progress ?? "") : "",
875
+ typeof detail() === "string" ? String(detail()) : "",
876
+ state(),
877
+ outcome(),
878
+ ]
879
+ .filter(Boolean)
880
+ .join(" · ")}
881
+ icon={icon()}
882
+ busy={props.busy && props.block.status === "running"}
883
+ bodyInset={false}
884
+ renderBody={() => (
885
+ <div class="flex min-w-0 flex-col gap-2">
886
+ <div
887
+ class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
888
+ tabIndex={0}
889
+ role="region"
890
+ aria-label={t().toolInputOutput}
891
+ >
892
+ <ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
893
+ <ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
894
+ </div>
895
+ {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
896
+ <Show when={image()}>
897
+ {(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}
898
+ </Show>
880
899
  </div>
881
- {/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
882
- <Show when={image()}>{(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}</Show>
883
- </div>
884
- )}
885
- />
900
+ )}
901
+ />
902
+ {/* Each request a website approval let through is a step of its own, with its full URL. */}
903
+ <Show when={websiteReceipts(props.block).length > 0}>
904
+ <WebsiteReceipts block={props.block} />
905
+ </Show>
906
+ </>
886
907
  );
887
908
  }
@@ -12,7 +12,7 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
12
12
  * `window.steer(text)` appends a pending steer as the controller does,
13
13
  * `window.reconnect(on)` sets whether the stream reconnects,
14
14
  * `window.continued` lists the messages that **Continue** sent,
15
- * `window.approvals` lists the approval decisions, and
15
+ * `window.approvals` lists the approval decisions, `window.revoked` the websites a receipt revoked, and
16
16
  * `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
17
17
  */
18
18
  const [state, setState] = createStore(emptyProjection());
@@ -20,9 +20,11 @@ const [reconnecting, setReconnecting] = createSignal(false);
20
20
  const continued: string[] = [];
21
21
  const opened: string[] = [];
22
22
  const approvals: unknown[] = [];
23
+ const revoked: string[] = [];
23
24
  Object.assign(window, {
24
25
  continued,
25
26
  approvals,
27
+ revoked,
26
28
  opened,
27
29
  reconnect: (on: boolean) => setReconnecting(on),
28
30
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
@@ -42,6 +44,7 @@ render(
42
44
  actions={{
43
45
  onContinueTurn: (message) => void continued.push(message),
44
46
  onApproval: async (request, input) => void approvals.push({ callId: request.callId, ...input }),
47
+ onRevokeWebsite: async (origin) => void revoked.push(origin),
45
48
  fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
46
49
  onOpenFile: (path) => void opened.push(path),
47
50
  }}
@@ -26,7 +26,7 @@ import { aiChatMessages } from "./messages";
26
26
  /** The active-turn coordinates an approval/tool action needs to resolve on the server. */
27
27
  export type AiTurnActionRequest = { turnId: string; callId: string; name: string };
28
28
 
29
- type ApprovalHandler = (request: AiTurnActionRequest, input: { approved: boolean; remember?: "always" }) => void | Promise<void>;
29
+ type ApprovalHandler = (request: AiTurnActionRequest, input: { approved: boolean; remember?: "chat" | "always" }) => void | Promise<void>;
30
30
  type FrontendToolResultHandler = (request: AiTurnActionRequest, result: unknown) => void | Promise<void>;
31
31
  type ForkMessageHandler = (entry: AiStoredMessage, input?: AiForkMessageInput) => void | Promise<void>;
32
32
  type RetryMessageHandler = (entry: AiStoredMessage, input?: AiRetryMessageInput) => void | Promise<void>;
@@ -42,6 +42,8 @@ export type AiChatActions = {
42
42
  /** Prevents turn-continuation actions while the current turn is stopping. */
43
43
  actionDisabled?: () => boolean;
44
44
  onApproval?: ApprovalHandler;
45
+ /** Revoke the approval that lets this chat read `origin` without asking; the next request asks again. */
46
+ onRevokeWebsite?: (origin: string) => Promise<void>;
45
47
  onFrontendToolResult?: FrontendToolResultHandler;
46
48
  onForkMessage?: ForkMessageHandler;
47
49
  onRetryMessage?: RetryMessageHandler;
@@ -98,6 +98,7 @@ const messages = i18n.define({
98
98
  openImage: ({ name }: { name: string }) => `Open image ${name}`,
99
99
  steered: "Conversation steered",
100
100
  groupLoad: "Loaded tools and guidance",
101
+ repeatCount: ({ count }: { count: number }) => `${count} times`,
101
102
  groupFailed: ({ count }: { count: number }) => `${count} failed`,
102
103
  groupRejected: ({ count }: { count: number }) => `${count} rejected`,
103
104
  receiptDone: ({ title }: { title: string }) => `Done: ${title}`,
@@ -120,6 +121,14 @@ const messages = i18n.define({
120
121
  approved: "Approved",
121
122
  rejected: "Rejected",
122
123
  alwaysApprove: "Always approve",
124
+ chatApprove: "Approve for this chat",
125
+ allowWebsiteForChat: "Allow this website for this chat",
126
+ websiteAllowedForChat: ({ count }: { count: number }) =>
127
+ `Allowed for this chat · ${count === 1 ? "1 request without asking" : `${count} requests without asking`}`,
128
+ revoke: "Revoke",
129
+ revokeFailed: "Could not revoke. Try again.",
130
+ revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
131
+ websiteRevoked: "Revoked. The next request asks again.",
123
132
  details: "Details",
124
133
  hideDetails: "Hide details",
125
134
  moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
@@ -389,6 +398,7 @@ const messages = i18n.define({
389
398
  openImage: ({ name }) => `Bild ${name} öffnen`,
390
399
  steered: "Richtung geändert",
391
400
  groupLoad: "Werkzeuge und Wissen geladen",
401
+ repeatCount: ({ count }) => `${count}-mal`,
392
402
  groupFailed: ({ count }) => `${count} fehlgeschlagen`,
393
403
  groupRejected: ({ count }) => `${count} abgelehnt`,
394
404
  receiptDone: ({ title }) => `Erledigt: ${title}`,
@@ -411,6 +421,14 @@ const messages = i18n.define({
411
421
  approved: "Freigegeben",
412
422
  rejected: "Abgelehnt",
413
423
  alwaysApprove: "Immer freigeben",
424
+ chatApprove: "Für diesen Chat freigeben",
425
+ allowWebsiteForChat: "Diese Website für diesen Chat erlauben",
426
+ websiteAllowedForChat: ({ count }: { count: number }) =>
427
+ `Für diesen Chat erlaubt · ${count === 1 ? "1 Abruf ohne Rückfrage" : `${count} Abrufe ohne Rückfrage`}`,
428
+ revoke: "Entziehen",
429
+ revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
430
+ revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
431
+ websiteRevoked: "Entzogen. Der nächste Abruf fragt wieder.",
414
432
  details: "Details",
415
433
  hideDetails: "Details ausblenden",
416
434
  moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
@@ -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 {
@@ -50,33 +52,85 @@ export const isHousekeepingTool = (tool: Tool): boolean => {
50
52
 
51
53
  const isStep = (entry: AiWorkEntry) => entry.kind === "tool" || entry.kind === "compaction";
52
54
 
55
+ /** The file, address, or query a folded row shows. A call without one never folds with others. */
56
+ const callTarget = (tool: Tool): string | undefined => {
57
+ const args = isRecord(tool.args) ? tool.args : {};
58
+ return [args.path, args.url, args.query].find((value): value is string => typeof value === "string" && value.trim() !== "")?.trim();
59
+ };
60
+
61
+ /** Arguments that page through one call's result, such as read_file's byte slices or an app's cursor and page size. */
62
+ const PAGING_ARGUMENTS = new Set(["offset", "length", "limit", "cursor", "after", "page", "pageSize"]);
63
+
64
+ type Fold = { kind: "housekeeping" } | { kind: "repeat"; name: string; target: string; identity: string };
65
+
66
+ /**
67
+ * What a step folds with: housekeeping with housekeeping, a running or finished call with the same call apart from
68
+ * paging, such as reading one file in parts. Repeated calls need a target; failed, rejected, and waiting calls never repeat.
69
+ */
70
+ const foldOf = (tool: Tool): Fold | null => {
71
+ if (isHousekeepingTool(tool)) return { kind: "housekeeping" };
72
+ const target = callTarget(tool);
73
+ if (!target || isFailedTool(tool) || (tool.status !== "completed" && tool.status !== "running")) return null;
74
+ const args = isRecord(tool.args) ? tool.args : {};
75
+ const identity = JSON.stringify(
76
+ Object.fromEntries(Object.entries(args).filter(([key]) => !PAGING_ARGUMENTS.has(key))),
77
+ (_key: string, value: unknown) =>
78
+ typeof value === "string"
79
+ ? value.trim()
80
+ : isRecord(value)
81
+ ? Object.fromEntries(
82
+ Object.keys(value)
83
+ .sort()
84
+ .map((key) => [key, value[key]]),
85
+ )
86
+ : value,
87
+ );
88
+ return { kind: "repeat", name: tool.name, target, identity };
89
+ };
90
+
91
+ const sameFold = (a: Fold, b: Fold): boolean =>
92
+ a.kind === "housekeeping" ? b.kind === "housekeeping" : b.kind === "repeat" && a.name === b.name && a.identity === b.identity;
93
+
53
94
  /**
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.
95
+ * The rows of the expanded work, in the original order. A run of at least two steps that fold together, with the
96
+ * reasoning between them, becomes one row: housekeeping, or consecutive instances of the same call apart from paging,
97
+ * such as reading one file in parts. Anything else between them, such as an intermediate text, ends the run. A run that
98
+ * would hold all but one of the work's steps shows its steps directly, so the work line never opens to a summary of
99
+ * itself. Entries listed in `shown` already show as rows of their own and never fold, so nothing a reader sees folds
100
+ * away under them.
101
+ * A folded row is keyed by its first tool, whose id is the same live and in history.
59
102
  */
60
103
  export function groupWorkBlocks(blocks: readonly AiTurnBlock[], shown: ReadonlySet<string> = new Set()): AiWorkItem[] {
61
104
  const entries = blocks.filter((block): block is AiWorkEntry | Text => block.kind !== "steer_message");
62
105
  const steps = entries.filter((entry) => entry.kind !== "text" && isStep(entry)).length;
63
106
  const items: AiWorkItem[] = [];
107
+ let fold: Fold | null = null;
64
108
  let run: AiWorkEntry[] = [];
65
109
  let trailing: AiWorkEntry[] = [];
66
110
  const flush = () => {
67
- if (run.length > 0) {
111
+ if (fold && run.length > 0) {
68
112
  const runSteps = run.filter(isStep).length;
69
- if (runSteps >= 2 && steps - runSteps >= 2) items.push({ kind: "housekeeping", id: run[0]!.id, entries: run });
113
+ const id = run[0]!.id;
114
+ if (runSteps >= 2 && steps - runSteps >= 2)
115
+ items.push(
116
+ fold.kind === "housekeeping"
117
+ ? { kind: "housekeeping", id, entries: run }
118
+ : { kind: "repeat", id, target: fold.target, entries: run },
119
+ );
70
120
  else for (const entry of run) items.push({ kind: "step", entry });
71
121
  }
72
- // Reasoning after the last housekeeping step belongs to what comes next.
122
+ // Reasoning after the last folded step belongs to what comes next.
73
123
  for (const entry of trailing) items.push({ kind: "step", entry });
124
+ fold = null;
74
125
  run = [];
75
126
  trailing = [];
76
127
  };
77
128
  for (const entry of entries) {
78
129
  const foldable = !shown.has(entry.id);
79
- if (foldable && entry.kind === "tool" && isHousekeepingTool(entry)) {
130
+ const next = foldable && entry.kind === "tool" ? foldOf(entry) : null;
131
+ if (next && entry.kind === "tool") {
132
+ if (fold && !sameFold(fold, next)) flush();
133
+ fold = next;
80
134
  run.push(...trailing, entry);
81
135
  trailing = [];
82
136
  } else if (foldable && entry.kind === "thinking" && run.length > 0) trailing.push(entry);
@@ -72,7 +72,8 @@ const toolNames = i18n.define({
72
72
  code_restore: "Restore app version",
73
73
  code_update: "Update app details",
74
74
  code_create: "Create app",
75
- code_write: "Save app files",
75
+ code_write: "Save app file",
76
+ code_import: "Import app files",
76
77
  code_remove: "Remove app file",
77
78
  },
78
79
  de: {
@@ -140,7 +141,8 @@ const toolNames = i18n.define({
140
141
  code_restore: "App-Version wiederherstellen",
141
142
  code_update: "App-Details ändern",
142
143
  code_create: "App erstellen",
143
- code_write: "App-Dateien speichern",
144
+ code_write: "App-Datei speichern",
145
+ code_import: "App-Dateien importieren",
144
146
  code_remove: "App-Datei entfernen",
145
147
  },
146
148
  },
@@ -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,
@@ -144,7 +144,7 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
144
144
  if (name === "code_present") return withTarget(t.stepPreparingView);
145
145
  if (isCardToolName(name)) return t.stepPreparingCard;
146
146
  if (isChartToolName(name)) return t.stepPreparingChart;
147
- if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
147
+ if (["code_write", "code_import", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
148
148
  return withTarget(t.stepWritingCode);
149
149
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
150
150
  if (name === "code_check") return t.stepCheckingApp;
@@ -236,6 +236,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
236
236
  tone={working() ? "ai" : "neutral"}
237
237
  busy={working()}
238
238
  trailing={trailing()}
239
+ bodyInset={false}
239
240
  renderBody={() => <AiWorkSteps blocks={layout().work} active={working()} />}
240
241
  />
241
242
  );
@@ -275,7 +276,27 @@ function WorkEntryRow(props: { entry: AiWorkEntry; busy: boolean }) {
275
276
  );
276
277
  }
277
278
 
278
- /** Housekeeping folded into one row at step level. It opens in place: its steps follow at the same indent. */
279
+ /** The steps of an opened group, keyed by block id, so a row keeps its element and focus when its block is replaced. */
280
+ function WorkEntries(props: { entries: AiWorkEntry[]; active: boolean }) {
281
+ const entries = createMemo(() => new Map(props.entries.map((entry) => [entry.id, entry])));
282
+ const ids = createMemo(() => [...entries().keys()]);
283
+ return (
284
+ <div class="ai-turn-steps">
285
+ <For each={ids()}>
286
+ {(id, index) => (
287
+ <Show when={entries().get(id)}>
288
+ {(entry) => <WorkEntryRow entry={entry()} busy={props.active && index() === ids().length - 1} />}
289
+ </Show>
290
+ )}
291
+ </For>
292
+ </div>
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Housekeeping folded into one row at step level, with its count at the end like the work line's. It opens in place:
298
+ * its steps follow at the same indent.
299
+ */
279
300
  function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
280
301
  const locale = useLocale();
281
302
  const last = () => props.entries.at(-1)!;
@@ -283,17 +304,33 @@ function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active:
283
304
  <AiToolActivity
284
305
  blockId={`group:${props.id}`}
285
306
  label={aiChatMessages(locale()).groupLoad}
286
- description={countWorkSteps(props.entries, locale())}
307
+ trailing={<span class="ai-turn-work__meta">{countWorkSteps(props.entries, locale())}</span>}
287
308
  icon="ti ti-stack-2"
288
309
  bodyInset={false}
289
310
  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
- )}
311
+ renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
312
+ />
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Consecutive instances of one call apart from paging, such as reading one file in parts, folded into one row that
318
+ * 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.
319
+ */
320
+ function RepeatedSteps(props: { id: string; target: string; entries: AiWorkEntry[]; active: boolean }) {
321
+ const locale = useLocale();
322
+ const tools = () => props.entries.filter((entry): entry is ToolBlock => entry.kind === "tool");
323
+ const first = () => tools()[0]!;
324
+ return (
325
+ <AiToolActivity
326
+ blockId={`repeat:${props.id}`}
327
+ label={first().presentation?.title ?? displayToolName(first().name, locale())}
328
+ description={props.target}
329
+ trailing={<span class="ai-turn-work__meta">{aiChatMessages(locale()).repeatCount({ count: tools().length })}</span>}
330
+ icon={aiToolIcon(first().name, first().presentation?.appIcon)}
331
+ bodyInset={false}
332
+ busy={props.active && tools().at(-1)?.status === "running"}
333
+ renderBody={() => <WorkEntries entries={props.entries} active={props.active} />}
297
334
  />
298
335
  );
299
336
  }
@@ -311,13 +348,19 @@ function WorkText(props: { text: string }) {
311
348
  }
312
349
 
313
350
  const workItemKey = (item: AiWorkItem): string =>
314
- item.kind === "text" ? `text:${item.block.id}` : item.kind === "step" ? `step:${item.entry.id}` : `group:${item.id}`;
351
+ item.kind === "text"
352
+ ? `text:${item.block.id}`
353
+ : item.kind === "step"
354
+ ? `step:${item.entry.id}`
355
+ : item.kind === "repeat"
356
+ ? `repeat:${item.id}`
357
+ : `group:${item.id}`;
315
358
 
316
359
  /**
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.
360
+ * Expanded work, at the work line's indent and one size: intermediate texts as quiet paragraphs, the steps between
361
+ * them directly, and housekeeping or repeated steps folded at step level. A row shown while this list is open never
362
+ * folds away under the reader, also when reasoning or arguments that would fold it arrive later; the next time it
363
+ * opens, it folds like in history.
321
364
  */
322
365
  function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
323
366
  const shown = new Set<string>();
@@ -344,12 +387,19 @@ function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
344
387
  const value = item();
345
388
  return value?.kind === "housekeeping" ? value : undefined;
346
389
  };
390
+ const repeat = () => {
391
+ const value = item();
392
+ return value?.kind === "repeat" ? value : undefined;
393
+ };
347
394
  const active = () => props.active && index() === keys().length - 1;
348
395
  return (
349
396
  <Switch>
350
397
  <Match when={text()}>{(value) => <WorkText text={value()} />}</Match>
351
398
  <Match when={step()}>{(entry) => <WorkEntryRow entry={entry()} busy={active()} />}</Match>
352
399
  <Match when={group()}>{(value) => <HousekeepingGroup id={value().id} entries={value().entries} active={active()} />}</Match>
400
+ <Match when={repeat()}>
401
+ {(value) => <RepeatedSteps id={value().id} target={value().target} entries={value().entries} active={active()} />}
402
+ </Match>
353
403
  </Switch>
354
404
  );
355
405
  }}
@@ -641,14 +691,18 @@ function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean })
641
691
  }
642
692
 
643
693
  /**
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.
694
+ * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. Once a step
695
+ * started after it, it reads quietly like the texts in the expanded work; a trailing text may still be the answer and
696
+ * keeps answer typography. A status is not read token by token: screen readers hear each status once it is complete,
697
+ * and that the answer is ready.
646
698
  */
647
699
  function AiTurnText(props: {
648
700
  blocks: Accessor<Extract<AiTurnBlock, { kind: "text" }>[]>;
649
701
  live: Accessor<boolean>;
650
702
  /** The turn has work steps, so its live text is a status rather than the answer itself. */
651
703
  folding: Accessor<boolean>;
704
+ /** A step started after the text, so it can no longer be the answer: it reads quietly. */
705
+ quiet: Accessor<boolean>;
652
706
  /** An earlier segment of a split turn: it stops being live while the turn goes on. */
653
707
  earlier: Accessor<boolean>;
654
708
  }) {
@@ -662,6 +716,7 @@ function AiTurnText(props: {
662
716
  );
663
717
  const html = createMemo(() => markdown.renderSync(source(), { locale: locale() }));
664
718
  const shownId = createMemo(() => props.blocks()[0]?.id);
719
+ const quiet = createMemo(() => props.quiet());
665
720
  const [minHeight, setMinHeight] = createSignal<number | null>(null);
666
721
  let element!: HTMLDivElement;
667
722
  let lastHeight = 0;
@@ -673,14 +728,21 @@ function AiTurnText(props: {
673
728
  observer.observe(element);
674
729
  onCleanup(() => observer.disconnect());
675
730
  });
731
+ /** The quiet text's height in answer typography, measured in place before the browser paints. */
732
+ const answerHeight = () => {
733
+ element.classList.remove("ai-turn__text--status");
734
+ const height = element.offsetHeight;
735
+ element.classList.add("ai-turn__text--status");
736
+ return height;
737
+ };
738
+ // A new status keeps the height of the one before it. A quiet status keeps the height it has as the answer, also when
739
+ // it first shows quietly after a reload, so nothing below moves when it is replaced or becomes the answer again.
676
740
  createEffect(
677
- on(
678
- shownId,
679
- () => {
680
- if (props.live() && lastHeight > 0) setMinHeight((current) => Math.max(current ?? 0, lastHeight));
681
- },
682
- { defer: true },
683
- ),
741
+ on([shownId, quiet], ([id, isQuiet], previous) => {
742
+ if (!props.live()) return;
743
+ const height = Math.max(previous && previous[0] !== id ? lastHeight : 0, isQuiet ? answerHeight() : 0);
744
+ if (height > 0) setMinHeight((current) => Math.max(current ?? 0, height));
745
+ }),
684
746
  );
685
747
  createEffect(() => {
686
748
  if (!props.live()) setMinHeight(null);
@@ -701,6 +763,7 @@ function AiTurnText(props: {
701
763
  <div
702
764
  ref={element}
703
765
  class="ai-turn__text"
766
+ classList={{ "ai-turn__text--status": quiet() }}
704
767
  aria-live={status() ? "off" : undefined}
705
768
  style={minHeight() ? { "min-height": `${minHeight()}px` } : undefined}
706
769
  >
@@ -757,7 +820,13 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
757
820
  </Show>
758
821
  <For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
759
822
  <Show when={layout().text.length > 0}>
760
- <AiTurnText blocks={() => layout().text} live={live} folding={() => layout().steps > 0} earlier={() => props.segment().earlier} />
823
+ <AiTurnText
824
+ blocks={() => layout().text}
825
+ live={live}
826
+ folding={() => layout().steps > 0}
827
+ quiet={() => layout().textIsStatus}
828
+ earlier={() => props.segment().earlier}
829
+ />
761
830
  </Show>
762
831
  <For each={actionIds()}>
763
832
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}