@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.
- package/package.json +3 -2
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/registry.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +134 -93
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +36 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/capabilities.ts +34 -12
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +57 -30
- package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +324 -2
- package/src/ai/chat/presentation.tsx +257 -103
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +609 -0
- package/src/ai/client/projection.ts +37 -6
- package/src/ai/executor.ts +38 -5
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +45 -2
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/protocol.ts +20 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/quota-provider.ts +14 -5
- package/src/ai/skills.ts +5 -2
- package/src/ai/store.ts +108 -3
- package/src/ai/stream.ts +2 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +12 -2
- package/src/browser/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/contracts/registry.ts +2 -0
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/effects.css +69 -0
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
return (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
163
|
+
views: TurnViews,
|
|
93
164
|
locale: string,
|
|
94
|
-
): ChatTimelineItem[] =>
|
|
95
|
-
buildAiMessageTimeline([...messages])
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
:
|
|
208
|
-
|
|
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 = (
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
233
|
-
if (segment.type === "survey")
|
|
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
|
|
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
|
|
350
|
+
id,
|
|
252
351
|
role: "assistant",
|
|
253
|
-
status: turn.status === "running" &&
|
|
254
|
-
class:
|
|
255
|
-
content: (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
283
|
-
const
|
|
284
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
47
|
+
const t = aiChatMessages(locale);
|
|
44
48
|
const categories = tools.map((tool) => {
|
|
45
|
-
if (tool.name === "todo_write") return
|
|
46
|
-
if (tool.name
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
if (
|
|
51
|
-
if (
|
|
52
|
-
if (["
|
|
53
|
-
|
|
54
|
-
if (tool.name.startsWith("web_") || tool.name === "fetch_file") return
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
}
|