@k2b/cloud 0.24.0 → 0.26.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 (53) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/capabilities.ts +12 -0
  3. package/src/_internal/registry.ts +1 -0
  4. package/src/access/GroupCoverage.tsx +175 -0
  5. package/src/access/PermissionEditor.tsx +134 -93
  6. package/src/access/managers.ts +32 -0
  7. package/src/access/messages.ts +36 -0
  8. package/src/ai/approval-routes.ts +5 -5
  9. package/src/ai/capabilities.ts +34 -12
  10. package/src/ai/chat/blocks.tsx +86 -179
  11. package/src/ai/chat/builtin-tools.tsx +57 -30
  12. package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
  13. package/src/ai/chat/message-actions.tsx +6 -2
  14. package/src/ai/chat/message-utils.ts +17 -14
  15. package/src/ai/chat/messages.ts +324 -2
  16. package/src/ai/chat/presentation.tsx +257 -103
  17. package/src/ai/chat/tool-groups.ts +55 -35
  18. package/src/ai/chat/turn-layout.ts +141 -0
  19. package/src/ai/chat/turn-view.tsx +609 -0
  20. package/src/ai/client/projection.ts +37 -6
  21. package/src/ai/executor.ts +38 -5
  22. package/src/ai/memory-learning.ts +7 -12
  23. package/src/ai/memory-workflow-evidence.ts +10 -2
  24. package/src/ai/migrate.ts +45 -2
  25. package/src/ai/model-access.ts +4 -2
  26. package/src/ai/prefs.ts +19 -5
  27. package/src/ai/projects.ts +5 -2
  28. package/src/ai/protocol.ts +20 -4
  29. package/src/ai/provider-fetch.ts +67 -15
  30. package/src/ai/provider-retry.ts +105 -0
  31. package/src/ai/quota-provider.ts +14 -5
  32. package/src/ai/skills.ts +5 -2
  33. package/src/ai/store.ts +108 -3
  34. package/src/ai/stream.ts +2 -0
  35. package/src/ai/timeline.ts +9 -11
  36. package/src/ai/turn-timing.ts +31 -3
  37. package/src/ai/types.ts +12 -2
  38. package/src/browser/FileChooser.tsx +600 -0
  39. package/src/browser/choose-files.browser-harness.tsx +32 -0
  40. package/src/browser/choose-files.tsx +70 -0
  41. package/src/browser/file-chooser-messages.ts +86 -0
  42. package/src/browser/file-providers.ts +217 -0
  43. package/src/browser/files.tsx +23 -0
  44. package/src/cli/admin/account-administration.ts +2 -2
  45. package/src/contracts/registry.ts +2 -0
  46. package/src/server/index.ts +1 -0
  47. package/src/server/services/access.ts +44 -11
  48. package/src/server/services/index.ts +1 -0
  49. package/src/shared/app-presentation.ts +10 -2
  50. package/src/ssr/platform-messages.ts +2 -2
  51. package/src/styles/effects.css +69 -0
  52. package/src/styles/file-chooser.css +169 -0
  53. package/src/styles/global.css +1 -0
@@ -1,16 +1,28 @@
1
- import { Button, Chat, type ChatTimelineItem, useLocale } from "@k2b/ui";
2
- import { type Accessor, createEffect, createMemo, createSignal, type JSX, onCleanup, Show } from "solid-js";
1
+ import type { ChatTimelineItem } from "@k2b/ui";
2
+ import { useLocale } from "@k2b/ui";
3
+ import {
4
+ type Accessor,
5
+ createEffect,
6
+ createMemo,
7
+ createRoot,
8
+ createSignal,
9
+ getOwner,
10
+ type JSX,
11
+ onCleanup,
12
+ type Setter,
13
+ Show,
14
+ } from "solid-js";
3
15
  import type { AiActiveTurn } from "../client/projection";
4
16
  import { type AiActiveTurnSegment, splitActiveTurnBlocks } from "../protocol";
5
- import { type AiAssistantTimelineItem, buildAiMessageTimeline, copyTextFromAssistantEntries } from "../timeline";
17
+ import { type AiAssistantTimelineItem, buildAiMessageTimeline } from "../timeline";
6
18
  import type { AiConversationTimelineEntry, AiStoredMessage } from "../types";
7
- import { AiTurnBlockList } from "./blocks";
8
19
  import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
9
- import { formatWorkedDuration, isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
10
- import { aiChatMessages } from "./messages";
20
+ import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
11
21
  import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
22
+ import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, waitsForUser } from "./turn-layout";
12
23
  import { TurnNavigator } from "./turn-navigator";
13
24
  import { activeTimelineSeq } from "./turn-navigator-utils";
25
+ import { type AiTurnDuration, type AiTurnSegment, AiTurnView } from "./turn-view";
14
26
  import {
15
27
  aiSteerMessageText,
16
28
  aiUserMessageAttachments,
@@ -28,14 +40,6 @@ export type AiChatTimelineSession = {
28
40
  export { type AiChatActions, AiChatActionsProvider };
29
41
 
30
42
  type AssistantBlock = AiAssistantTimelineItem["blocks"][number];
31
-
32
- // Activity blocks span the full message column so disclosure chevrons share one
33
- // right edge. Tool and compaction blocks count even while busy; reasoning counts
34
- // only once it has text, because empty reasoning renders at most a busy row and
35
- // must not widen a prose-only reply.
36
- const isWideBlock = (block: AssistantBlock) =>
37
- block.kind === "tool" || block.kind === "compaction" || (block.kind === "thinking" && block.text.trim().length > 0);
38
-
39
43
  type SurveyResultBlock = Extract<AssistantBlock, { kind: "tool" }>;
40
44
  type SurveySegment = { type: "assistant"; blocks: AssistantBlock[] } | { type: "survey"; block: SurveyResultBlock };
41
45
 
@@ -68,31 +72,104 @@ const surveyItem = (block: SurveyResultBlock, turnId: string): ChatTimelineItem
68
72
  content: <CloudSurveyResultBlock args={block.args} result={block.result} />,
69
73
  });
70
74
 
71
- export function AiAssistantContent(props: {
72
- item: AiAssistantTimelineItem;
73
- disclosureState?: AiToolDisclosureState;
74
- segmentId?: string;
75
- }): JSX.Element {
76
- const locale = useLocale();
77
- const turnId = () => props.item.loopId ?? props.item.id;
78
- return (
79
- <div class="flex flex-col gap-2">
80
- <Chat.Activity
81
- icon="ti ti-route"
82
- label={`${locale().startsWith("de") ? "Gearbeitet für" : "Worked for"} ${formatWorkedDuration(props.item.workedMs)}`}
83
- />
84
- <AiTurnBlockList blocks={props.item.blocks} turnId={turnId()} disclosureState={props.disclosureState} />
85
- </div>
86
- );
87
- }
75
+ /**
76
+ * Timeline ids of a turn's assistant segments. A segment is named by what opened it: `start`, the steering message, or
77
+ * the accepted survey answer before it. A live turn and its history produce the same ids, also for consecutive steering
78
+ * messages, and loading older history does not rename a segment, so the timeline keeps the same elements.
79
+ */
80
+ const createSegmentIds = () => {
81
+ const used = new Map<string, number>();
82
+ return (turnId: string, opener: string) => {
83
+ const base = `ai-turn:${turnId}:${opener}`;
84
+ // A turn that was compacted while it ran is split in history; its later part continues the same name.
85
+ const count = used.get(base) ?? 0;
86
+ used.set(base, count + 1);
87
+ return count === 0 ? base : `${base}:${count}`;
88
+ };
89
+ };
90
+
91
+ const steerOpener = (steerId: string) => `steer:${steerId}`;
92
+ const surveyOpener = (callId: string) => `survey:${callId}`;
93
+
94
+ /** History stores consecutive steering messages before the blocks they steered; the last one opened the segment. */
95
+ const leadingSteerOpener = (blocks: readonly AssistantBlock[]): string | null => {
96
+ let opener: string | null = null;
97
+ for (const block of blocks) {
98
+ if (block.kind !== "steer_applied") break;
99
+ opener = steerOpener(block.steerId);
100
+ }
101
+ return opener;
102
+ };
103
+
104
+ /** History phase of a finished loop, from how its loop ended. */
105
+ const storedPhase = (entries: readonly AiStoredMessage[]): AiTurnPhase => {
106
+ const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
107
+ if (!reason || reason === "stop") return "completed";
108
+ return reason === "aborted" ? "stopped" : "failed";
109
+ };
110
+
111
+ /** Responses with a work line or rich places span the message column so their right edges align. */
112
+ const isWideLayout = (layout: AiTurnLayout) => layout.showWork || layout.results.length > 0 || layout.actions.length > 0;
113
+
114
+ const isEmptyLayout = (layout: AiTurnLayout) => !isWideLayout(layout) && layout.text.length === 0;
115
+
116
+ /**
117
+ * Turn views live in their own roots, keyed by timeline id. A changed segment updates its view instead of
118
+ * replacing it, so host state inside a turn, such as a running Studio session, survives every update.
119
+ */
120
+ const createTurnViews = (disclosureState: AiToolDisclosureState) => {
121
+ const owner = getOwner();
122
+ const views = new Map<string, { set: Setter<AiTurnSegment>; content: JSX.Element; dispose: () => void }>();
123
+ onCleanup(() => {
124
+ for (const view of views.values()) view.dispose();
125
+ views.clear();
126
+ });
127
+ return {
128
+ content(segment: AiTurnSegment): JSX.Element {
129
+ const existing = views.get(segment.id);
130
+ if (existing) {
131
+ existing.set(() => segment);
132
+ return existing.content;
133
+ }
134
+ return createRoot((dispose) => {
135
+ const [current, set] = createSignal(segment);
136
+ const content = <AiTurnView segment={current} disclosureState={disclosureState} />;
137
+ views.set(segment.id, { set, content, dispose });
138
+ return content;
139
+ }, owner);
140
+ },
141
+ retain(ids: ReadonlySet<string>) {
142
+ for (const [id, view] of views) {
143
+ if (ids.has(id)) continue;
144
+ view.dispose();
145
+ views.delete(id);
146
+ }
147
+ },
148
+ };
149
+ };
150
+
151
+ type TurnViews = ReturnType<typeof createTurnViews>;
152
+
153
+ /** A segment's final text is what Copy copies; intermediate texts stay in the work line. */
154
+ const finalText = (layout: AiTurnLayout) =>
155
+ layout.text
156
+ .map((block) => block.text)
157
+ .join("\n\n")
158
+ .trim();
88
159
 
89
160
  const storedItems = (
90
161
  messages: readonly AiStoredMessage[],
91
162
  actions: AiChatActions,
92
- disclosureState: AiToolDisclosureState,
163
+ views: TurnViews,
93
164
  locale: string,
94
- ): ChatTimelineItem[] =>
95
- buildAiMessageTimeline([...messages]).flatMap((item): ChatTimelineItem | ChatTimelineItem[] => {
165
+ ): ChatTimelineItem[] => {
166
+ const timeline = buildAiMessageTimeline([...messages]);
167
+ const lastItemOfLoop = new Map<string, number>();
168
+ timeline.forEach((item, index) => {
169
+ if (item.type === "assistant" && item.loopId) lastItemOfLoop.set(item.loopId, index);
170
+ });
171
+ const segmentId = createSegmentIds();
172
+ return timeline.flatMap((item, itemIndex): ChatTimelineItem | ChatTimelineItem[] => {
96
173
  if (item.type === "user") {
97
174
  const text = aiUserMessageText(item.entry);
98
175
  const agentMessage = item.entry.meta?.agentMessage;
@@ -167,72 +244,87 @@ const storedItems = (
167
244
  }
168
245
 
169
246
  const actionEntry = item.actionEntry?.compactedAt ? null : item.actionEntry;
170
- const copyText = copyTextFromAssistantEntries(item.entries);
247
+ const lastOfLoop = !item.loopId || lastItemOfLoop.get(item.loopId) === itemIndex;
248
+ const phase = lastOfLoop ? storedPhase(item.entries) : "completed";
171
249
  const segments = splitSurveyResults(item.blocks);
172
250
  if (segments.length === 0) segments.push({ type: "assistant", blocks: [] });
173
251
  const lastAssistant = segments.findLastIndex((segment) => segment.type === "assistant");
174
- return segments.map((segment, index): ChatTimelineItem => {
175
- if (segment.type === "survey") return surveyItem(segment.block, item.loopId ?? item.id);
176
- return {
177
- kind: "message",
178
- id: index === 0 ? item.id : `${item.id}:${segment.blocks[0]!.id}`,
179
- role: "assistant",
180
- createdAt: actionEntry?.createdAt ?? item.entries.at(-1)?.createdAt,
181
- class: segment.blocks.some(isWideBlock) ? "ai-chat-message-wide" : undefined,
182
- content: (
183
- <>
184
- <Show when={index === 0 && item.entries.find((entry) => entry.meta?.scheduledTask)?.meta?.scheduledTask}>
185
- {(task) => (
186
- <Button
187
- variant="ghost"
188
- size="xs"
189
- class="mb-2"
190
- onClick={() => actions.onOpenScheduledTaskRun?.(task().taskId, task().occurrenceId)}
191
- disabled={!actions.onOpenScheduledTaskRun}
192
- >
193
- <i class="ti ti-calendar-time" aria-hidden="true" /> {aiChatMessages(locale).backgroundRun}
194
- </Button>
195
- )}
196
- </Show>
197
- <AiAssistantContent
198
- item={{ ...item, blocks: segment.blocks }}
199
- segmentId={segments.length > 1 ? `${item.id}:${index}` : undefined}
200
- disclosureState={disclosureState}
201
- />
202
- </>
203
- ),
204
- actions:
205
- actionEntry && index === lastAssistant
206
- ? createAssistantMessageActions({ entry: actionEntry, entries: item.entries, copyText, actions })
207
- : undefined,
208
- actionDisplay: "inline",
209
- };
252
+ const scheduledTask = item.entries.find((entry) => entry.meta?.scheduledTask)?.meta?.scheduledTask ?? null;
253
+ let firstSegment = true;
254
+ let opener = leadingSteerOpener(item.blocks) ?? "start";
255
+ return segments.flatMap((segment, index): ChatTimelineItem[] => {
256
+ const turnId = item.loopId ?? item.id;
257
+ if (segment.type === "survey") {
258
+ opener = surveyOpener(segment.block.callId);
259
+ return [surveyItem(segment.block, turnId)];
260
+ }
261
+ const segmentOpener = opener;
262
+ const last = lastOfLoop && index === lastAssistant;
263
+ const segmentPhase = last ? phase : "completed";
264
+ const layout = layoutAiTurn(segment.blocks, { phase: segmentPhase, codePresentations: Boolean(actions.renderCodePresentation) });
265
+ const messageActions =
266
+ actionEntry && index === lastAssistant
267
+ ? createAssistantMessageActions({ entry: actionEntry, entries: item.entries, copyText: finalText(layout), actions })
268
+ : undefined;
269
+ const task = firstSegment ? scheduledTask : null;
270
+ firstSegment = false;
271
+ if (isEmptyLayout(layout) && !messageActions && !task) return [];
272
+ const id = item.loopId ? segmentId(item.loopId, segmentOpener) : `${item.id}:${index}`;
273
+ return [
274
+ {
275
+ kind: "message",
276
+ id,
277
+ role: "assistant",
278
+ createdAt: actionEntry?.createdAt ?? item.entries.at(-1)?.createdAt,
279
+ class: isWideLayout(layout) ? "ai-chat-message-wide" : undefined,
280
+ content: views.content({
281
+ id,
282
+ turnId,
283
+ phase: segmentPhase,
284
+ layout,
285
+ earlier: !last,
286
+ duration: last ? () => ({ workedMs: item.workedMs, waitingMs: null }) : () => null,
287
+ scheduledTask: task,
288
+ }),
289
+ actions: messageActions,
290
+ actionDisplay: "inline",
291
+ },
292
+ ];
210
293
  });
211
294
  });
295
+ };
212
296
 
213
- const activeItems = (turn: AiActiveTurn | null, actions: AiChatActions, disclosureState: AiToolDisclosureState): ChatTimelineItem[] => {
297
+ const activeItems = (
298
+ turn: AiActiveTurn | null,
299
+ actions: AiChatActions,
300
+ views: TurnViews,
301
+ duration: Accessor<AiTurnDuration | null>,
302
+ ): ChatTimelineItem[] => {
214
303
  if (!turn) return [];
215
304
  const segments = splitActiveTurnBlocks(turn.blocks).flatMap((segment): (AiActiveTurnSegment | SurveySegment)[] =>
216
305
  segment.type === "steer" ? [segment] : splitSurveyResults(segment.blocks),
217
306
  );
218
- // Keep the shared assistant progress indicator after the accepted answer.
219
- if (turn.status === "running" && segments.at(-1)?.type === "survey") segments.push({ type: "assistant", blocks: [] });
220
- if (segments.length === 0) {
221
- return [
222
- {
223
- kind: "message",
224
- id: `${turn.turnId}-pending`,
225
- role: "assistant",
226
- status: "streaming",
227
- anchorId: turn.seq,
228
- },
229
- ];
230
- }
307
+ // A running turn ends with an assistant item, so its progress indicator and a retry wait stay at the end: before the
308
+ // first model block, after an accepted answer, and below a steering message that waits for the next model call.
309
+ const tail = segments.at(-1)?.type;
310
+ const steerTail = turn.status === "running" && tail === "steer";
311
+ if (segments.length === 0 || steerTail || (turn.status === "running" && tail === "survey"))
312
+ segments.push({ type: "assistant", blocks: [] });
313
+ const lastAssistant = segments.findLastIndex((segment) => segment.type === "assistant");
314
+ // Until the model takes up the steering message, the segment above it is still the one at work.
315
+ const above = steerTail ? segments.findLastIndex((segment, index) => segment.type === "assistant" && index < lastAssistant) : -1;
316
+ const working = above >= 0 ? above : lastAssistant;
317
+ const segmentId = createSegmentIds();
318
+ let opener = "start";
231
319
 
232
- return segments.map((segment, index): ChatTimelineItem => {
233
- if (segment.type === "survey") return surveyItem(segment.block, turn.turnId);
320
+ return segments.flatMap((segment, index): ChatTimelineItem | ChatTimelineItem[] => {
321
+ if (segment.type === "survey") {
322
+ opener = surveyOpener(segment.block.callId);
323
+ return surveyItem(segment.block, turn.turnId);
324
+ }
234
325
  if (segment.type === "steer") {
235
326
  const block = segment.block;
327
+ opener = steerOpener(block.steerId);
236
328
  return {
237
329
  kind: "message",
238
330
  id: `${turn.turnId}-steer-${block.id}`,
@@ -245,22 +337,78 @@ const activeItems = (turn: AiActiveTurn | null, actions: AiChatActions, disclosu
245
337
  };
246
338
  }
247
339
 
248
- const blocks = segment.blocks;
340
+ const last = index === lastAssistant;
341
+ const live = index === working;
342
+ const phase: AiTurnPhase = !live ? "completed" : turn.status === "waiting_for_action" ? "waiting" : "running";
343
+ const layout = layoutAiTurn(segment.blocks, { phase, codePresentations: Boolean(actions.renderCodePresentation) });
344
+ // Consecutive steering messages leave empty segments between them; history has none. The last segment stays, since
345
+ // it carries the progress indicator.
346
+ if (!last && isEmptyLayout(layout)) return [];
347
+ const id = segmentId(turn.turnId, opener);
249
348
  return {
250
349
  kind: "message",
251
- id: `${turn.turnId}-assistant-${index}`,
350
+ id,
252
351
  role: "assistant",
253
- status: turn.status === "running" && index === segments.length - 1 ? "streaming" : "complete",
254
- class: blocks.some(isWideBlock) ? "ai-chat-message-wide" : undefined,
255
- content: (
256
- <AiTurnBlockList
257
- blocks={blocks}
258
- turnId={turn.turnId}
259
- streaming={turn.status === "running" && index === segments.length - 1}
260
- active={index === segments.length - 1}
261
- disclosureState={disclosureState}
262
- />
263
- ),
352
+ status: turn.status === "running" && last ? "streaming" : "complete",
353
+ class: isWideLayout(layout) ? "ai-chat-message-wide" : undefined,
354
+ content: views.content({
355
+ id,
356
+ turnId: turn.turnId,
357
+ phase,
358
+ layout,
359
+ earlier: !live,
360
+ duration: live ? duration : () => null,
361
+ retrying: last && turn.providerRetry === true,
362
+ }),
363
+ };
364
+ });
365
+ };
366
+
367
+ /**
368
+ * Work time of the active turn: wall time minus time spent waiting for the user. A state snapshot seeds it after a
369
+ * reconnect; the clock stands while the turn waits for an approval or an answer and continues from the same value
370
+ * afterwards.
371
+ */
372
+ const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDuration | null> => {
373
+ const [now, setNow] = createSignal(Date.now());
374
+ const running = createMemo(() => source.activeTurn() !== null);
375
+ createEffect(() => {
376
+ if (!running() || typeof window === "undefined") return;
377
+ setNow(Date.now());
378
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
379
+ onCleanup(() => window.clearInterval(timer));
380
+ });
381
+ let state: { turnId: string; seed: string; startedAt: number; waitMs: number; waitingSince: number | null } | null = null;
382
+ return createMemo(() => {
383
+ const turn = source.activeTurn();
384
+ const time = now();
385
+ if (!turn) {
386
+ state = null;
387
+ return null;
388
+ }
389
+ const seed = `${turn.createdAt}|${turn.actionWaitMs}|${turn.waitingSince}`;
390
+ if (!state || state.turnId !== turn.turnId || state.seed !== seed) {
391
+ const userMessage = source.messages().find((message) => message.loopId === turn.turnId && message.message.role === "user");
392
+ const started = Date.parse(turn.createdAt ?? userMessage?.createdAt ?? "");
393
+ const waitingSince = turn.waitingSince ? Date.parse(turn.waitingSince) : Number.NaN;
394
+ state = {
395
+ turnId: turn.turnId,
396
+ seed,
397
+ startedAt: Number.isFinite(started) ? Math.min(started, time) : state?.turnId === turn.turnId ? state.startedAt : time,
398
+ waitMs: turn.actionWaitMs ?? (state?.turnId === turn.turnId ? state.waitMs : 0),
399
+ waitingSince: Number.isFinite(waitingSince) ? Math.min(waitingSince, time) : null,
400
+ };
401
+ }
402
+ // A tool the browser runs by itself is work; only an approval or an answer the person owes stops the clock.
403
+ const waiting = turn.blocks.some(waitsForUser);
404
+ if (waiting && state.waitingSince === null) state.waitingSince = time;
405
+ if (!waiting && state.waitingSince !== null) {
406
+ state.waitMs += time - state.waitingSince;
407
+ state.waitingSince = null;
408
+ }
409
+ return {
410
+ workedMs: Math.max(0, (state.waitingSince ?? time) - state.startedAt - state.waitMs),
411
+ waitingMs: state.waitingSince === null ? null : Math.max(0, time - state.waitingSince),
264
412
  };
265
413
  });
266
414
  };
@@ -279,9 +427,15 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
279
427
  const actions = useAiChatActions();
280
428
  const locale = useLocale();
281
429
  const disclosureState = createAiToolDisclosureState();
282
- const stored = createMemo(() => storedItems(source.messages(), actions, disclosureState, locale()));
283
- const active = createMemo(() => activeItems(source.activeTurn(), actions, disclosureState));
284
- return createMemo(() => [...stored(), ...active()]);
430
+ const views = createTurnViews(disclosureState);
431
+ const clock = createActiveTurnClock(source);
432
+ const stored = createMemo(() => storedItems(source.messages(), actions, views, locale()));
433
+ const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock));
434
+ return createMemo(() => {
435
+ const items = [...stored(), ...active()];
436
+ views.retain(new Set(items.map((item) => item.id)));
437
+ return items;
438
+ });
285
439
  }
286
440
 
287
441
  export type AiChatTurnNavigatorProps = {
@@ -1,12 +1,15 @@
1
1
  import type { AiTurnBlock } from "../protocol";
2
- import { hasCapabilityTable } from "./capability-result";
3
- import { isCardToolName, isSurveyToolName, isTextEditorToolName } from "./message-utils";
2
+ import { aiChatMessages } from "./messages";
4
3
 
5
4
  type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
6
- type Group = { kind: "tools"; blocks: Tool[] } | { kind: "block"; block: AiTurnBlock };
5
+ type Text = Extract<AiTurnBlock, { kind: "text" }>;
6
+ export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
7
+ export type AiWorkGroup = { kind: "text"; block: Text } | { kind: "steps"; id: string; entries: AiWorkEntry[] };
7
8
 
9
+ /** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
8
10
  export function isFailedTool(tool: Tool): boolean {
9
- if (tool.isError || tool.status === "failed" || tool.status === "rejected") return true;
11
+ if (tool.status === "rejected") return false;
12
+ if (tool.isError || tool.status === "failed") return true;
10
13
  const result = tool.result;
11
14
  return (
12
15
  ["code_run", "code_inspect", "code_interact"].includes(tool.name) &&
@@ -17,43 +20,60 @@ export function isFailedTool(tool: Tool): boolean {
17
20
  );
18
21
  }
19
22
 
20
- /** Visible content and interactive decisions are boundaries; model request boundaries are not. */
21
- export function groupToolBlocks(blocks: readonly AiTurnBlock[]): Group[] {
22
- const groups: Group[] = [];
23
+ /**
24
+ * Split folded work into intermediate texts and the step groups between them. Reasoning stays inside its group so it
25
+ * does not break up the tools around it. A group is keyed by its first tool, whose id is the same live and in history.
26
+ */
27
+ export function groupWorkBlocks(blocks: readonly AiTurnBlock[]): AiWorkGroup[] {
28
+ const groups: AiWorkGroup[] = [];
23
29
  for (const block of blocks) {
24
- const ordinary =
25
- block.kind === "tool" &&
26
- !block.approval &&
27
- block.status !== "awaiting_approval" &&
28
- !["present", "code_present", "view_image", "code_secret"].includes(block.name) &&
29
- !isCardToolName(block.name) &&
30
- !isSurveyToolName(block.name) &&
31
- !isTextEditorToolName(block.name) &&
32
- !hasCapabilityTable(block);
33
- if (ordinary) {
34
- const last = groups.at(-1);
35
- if (last?.kind === "tools") last.blocks.push(block);
36
- else groups.push({ kind: "tools", blocks: [block] });
37
- } else groups.push({ kind: "block", block });
30
+ if (block.kind === "steer_message") continue;
31
+ if (block.kind === "text") {
32
+ groups.push({ kind: "text", block });
33
+ continue;
34
+ }
35
+ const last = groups.at(-1);
36
+ if (last?.kind === "steps") last.entries.push(block);
37
+ else groups.push({ kind: "steps", id: block.id, entries: [block] });
38
+ }
39
+ for (const group of groups) {
40
+ if (group.kind === "steps") group.id = (group.entries.find((entry) => entry.kind === "tool") ?? group.entries[0]!).id;
38
41
  }
39
42
  return groups;
40
43
  }
41
44
 
45
+ /** What a group of tools did, in categories; failures and rejections are counted separately. */
42
46
  export function summarizeToolGroup(tools: readonly Tool[], locale: string): string {
43
- const de = locale.startsWith("de");
47
+ const t = aiChatMessages(locale);
44
48
  const categories = tools.map((tool) => {
45
- if (tool.name === "todo_write") return de ? "Arbeitsplan aktualisiert" : "Updated working plan";
46
- if (tool.name.startsWith("code_")) return de ? "Mit Code gearbeitet" : "Worked with code";
47
- if (tool.presentation?.kind === "capability" || tool.name === "read_cloud_resource")
48
- return de ? "Cloud-Integration verwendet" : "Used Cloud integration";
49
- if (["read_file", "list_files", "view_image"].includes(tool.name)) return de ? "Dateien gelesen" : "Read files";
50
- if (tool.name === "write_file") return de ? "Dateien geschrieben" : "Wrote files";
51
- if (["markdown_to_pdf", "html_to_pdf"].includes(tool.name)) return de ? "PDFs erstellt" : "Created PDFs";
52
- if (["load_skill", "load_tools", "search_tools"].includes(tool.name))
53
- return de ? "Werkzeuge und Wissen geladen" : "Loaded tools and guidance";
54
- if (tool.name.startsWith("web_") || tool.name === "fetch_file") return de ? "Im Web recherchiert" : "Searched the web";
55
- return de ? "Werkzeuge verwendet" : "Used tools";
49
+ if (tool.name === "todo_write") return t.groupPlan;
50
+ if (tool.name === "present" || tool.name === "code_present" || tool.name === "card" || tool.name === "cloud_card")
51
+ return t.groupDelivered;
52
+ if (tool.name.startsWith("code_")) return t.groupCode;
53
+ if (tool.presentation?.kind === "capability" || tool.name === "read_cloud_resource") return t.groupCloud;
54
+ if (["read_file", "list_files", "view_image"].includes(tool.name)) return t.groupReadFiles;
55
+ if (tool.name === "write_file") return t.groupWriteFiles;
56
+ if (["markdown_to_pdf", "html_to_pdf"].includes(tool.name)) return t.groupPdf;
57
+ if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(tool.name)) return t.groupLoad;
58
+ if (tool.name.startsWith("web_") || tool.name === "fetch_file") return t.groupWeb;
59
+ if (tool.name === "memory") return t.groupMemory;
60
+ return t.groupTools;
56
61
  });
57
- const errors = tools.filter(isFailedTool).length;
58
- return [...new Set(categories)].join(", ") + (errors ? ` · ${errors} ${de ? "fehlgeschlagen" : "failed"}` : "");
62
+ return [...new Set(categories)].join(", ");
63
+ }
64
+
65
+ /** "9 steps · 1 failed · 1 rejected" for a group of steps. */
66
+ export function countWorkSteps(entries: readonly AiWorkEntry[], locale: string): string {
67
+ const t = aiChatMessages(locale);
68
+ const tools = entries.filter((entry): entry is Tool => entry.kind === "tool");
69
+ const steps = tools.length + entries.filter((entry) => entry.kind === "compaction").length;
70
+ const failed = tools.filter(isFailedTool).length;
71
+ const rejected = tools.filter((tool) => tool.status === "rejected").length;
72
+ return [
73
+ steps ? t.steps({ count: steps }) : "",
74
+ failed ? t.groupFailed({ count: failed }) : "",
75
+ rejected ? t.groupRejected({ count: rejected }) : "",
76
+ ]
77
+ .filter(Boolean)
78
+ .join(" · ");
59
79
  }