@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
@@ -0,0 +1,609 @@
1
+ import { announce, Button, ButtonLink, Chat, useLocale } from "@k2b/ui";
2
+ import {
3
+ type Accessor,
4
+ createEffect,
5
+ createMemo,
6
+ createSignal,
7
+ For,
8
+ type JSX,
9
+ Match,
10
+ on,
11
+ onCleanup,
12
+ onMount,
13
+ Show,
14
+ Switch,
15
+ untrack,
16
+ } from "solid-js";
17
+ import { markdown } from "../../shared";
18
+ import type { AiTurnBlock } from "../protocol";
19
+ import { ApprovalBlockView, CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
20
+ import { CapabilityTablePreview } from "./capability-table";
21
+ import { PresentToolBlock } from "./file-tools";
22
+ import { useAiChatActions } from "./message-actions";
23
+ import { aiToolIcon, capabilityErrorDescription, displayToolName, isCardToolName, isRecord, isSurveyToolName } from "./message-utils";
24
+ import { aiChatMessages } from "./messages";
25
+ import { AssistantMarkdownBlock } from "./primitives";
26
+ import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
27
+ import { type AiWorkEntry, countWorkSteps, groupWorkBlocks, summarizeToolGroup } from "./tool-groups";
28
+ import type { AiTurnAction, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
29
+ import { CloudCardBlock } from "./visual-tools";
30
+
31
+ type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
32
+ type Messages = ReturnType<typeof aiChatMessages>;
33
+
34
+ /** Work time of a turn: wall time minus time spent waiting for the user. `waitingMs` is set while it waits. */
35
+ export type AiTurnDuration = { workedMs: number; waitingMs: number | null };
36
+
37
+ /** One rendered turn segment. Steering and accepted survey answers split a turn into segments. */
38
+ export type AiTurnSegment = {
39
+ /** Timeline item id: the same for the live turn and its history. */
40
+ id: string;
41
+ turnId: string;
42
+ phase: AiTurnPhase;
43
+ layout: AiTurnLayout;
44
+ /** Earlier segments of a split turn show no duration; the last one carries the whole turn. */
45
+ earlier: boolean;
46
+ /** Read reactively, so a ticking clock does not rebuild the segment. */
47
+ duration: () => AiTurnDuration | null;
48
+ /** Shown above the work of a delivered scheduled result. */
49
+ scheduledTask?: { taskId: string; occurrenceId: string } | null;
50
+ /** A model call of the live turn waits for its retry. Set on the turn's last segment, so the wait ends the turn. */
51
+ retrying?: boolean;
52
+ };
53
+
54
+ const isLive = (phase: AiTurnPhase) => phase === "running" || phase === "waiting";
55
+
56
+ /** "12 s", "3 min", "1 h 5 min": seconds below a minute, then whole minutes. */
57
+ export const formatWorkDuration = (ms: number, t: Messages, long = false): string => {
58
+ const seconds = Math.max(1, Math.round(ms / 1000));
59
+ if (seconds < 60) return long ? t.durationSecondsLong({ seconds }) : t.durationSeconds({ seconds });
60
+ const minutes = Math.floor(seconds / 60);
61
+ if (minutes < 60) return long ? t.durationMinutesLong({ minutes }) : t.durationMinutes({ minutes });
62
+ const hours = Math.floor(minutes / 60);
63
+ return long ? t.durationHoursLong({ hours, minutes: minutes % 60 }) : t.durationHours({ hours, minutes: minutes % 60 });
64
+ };
65
+
66
+ /** A running clock: "2:41", "1:02:41". */
67
+ export const formatWorkClock = (ms: number): string => {
68
+ const total = Math.max(0, Math.floor(ms / 1000));
69
+ const hours = Math.floor(total / 3600);
70
+ const minutes = Math.floor((total % 3600) / 60);
71
+ const seconds = String(total % 60).padStart(2, "0");
72
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
73
+ };
74
+
75
+ const basename = (value: string) => value.slice(value.lastIndexOf("/") + 1) || value;
76
+
77
+ const toolTarget = (block: ToolBlock): string => {
78
+ const args = isRecord(block.args) ? block.args : {};
79
+ const value = [args.path, args.name, args.title, args.query].find((entry) => typeof entry === "string" && entry.trim());
80
+ return typeof value === "string" ? basename(value.trim()) : "";
81
+ };
82
+
83
+ /** The current step of a live turn, as a short progressive phrase. */
84
+ export const liveStepLabel = (block: AiTurnBlock | null, t: Messages): string => {
85
+ if (!block || block.kind === "thinking" || block.kind === "steer_applied" || block.kind === "steer_message") return t.stepThinking;
86
+ if (block.kind === "text") return t.stepWriting;
87
+ if (block.kind === "compaction") return block.status === "running" ? t.stepCompacting : t.stepThinking;
88
+ // A finished call means the model is deciding what to do next.
89
+ if (block.status !== "running" && block.status !== "awaiting_client") return t.stepThinking;
90
+ const target = toolTarget(block);
91
+ const withTarget = (label: string) => (target ? `${label} · ${target}` : label);
92
+ const name = block.name;
93
+ if (block.presentation?.kind === "capability") return withTarget(t.stepUsingApp({ app: block.presentation.appName }));
94
+ if (name === "read_file") return t.stepReading({ target });
95
+ if (name === "list_files") return t.stepListingFiles;
96
+ if (name === "view_image") return withTarget(t.stepViewingImage);
97
+ if (name === "write_file") return withTarget(t.stepWritingFile);
98
+ if (name === "todo_write") return t.stepPlan;
99
+ if (name === "present") return withTarget(t.stepDeliveringFile);
100
+ if (name === "code_present") return withTarget(t.stepPreparingView);
101
+ if (isCardToolName(name)) return t.stepPreparingCard;
102
+ if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
103
+ return withTarget(t.stepWritingCode);
104
+ if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
105
+ if (["code_inspect", "code_interact"].includes(name)) return t.stepCheckingApp;
106
+ if (name.startsWith("code_")) return t.stepCode;
107
+ if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
108
+ if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
109
+ if (name.startsWith("web_") || name === "fetch_file") return t.stepWeb;
110
+ if (name === "memory") return t.stepMemory;
111
+ if (name === "calculate") return t.stepCalculating;
112
+ return withTarget(t.stepTool);
113
+ };
114
+
115
+ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
116
+ const locale = useLocale();
117
+ const t = () => aiChatMessages(locale());
118
+ const segment = () => props.segment();
119
+ const layout = () => segment().layout;
120
+ const waiting = () => segment().phase === "waiting" && layout().waitingFor !== null;
121
+ const working = () => isLive(segment().phase) && !waiting();
122
+ const worked = () => {
123
+ const duration = segment().duration();
124
+ return !segment().earlier && duration && duration.workedMs > 0 ? duration.workedMs : null;
125
+ };
126
+ const steps = () => t().steps({ count: layout().steps });
127
+ const label = () => {
128
+ if (working()) return liveStepLabel(layout().current, t());
129
+ if (waiting()) return layout().waitingFor === "approval" ? t().waitingForApproval : t().waitingForAnswer;
130
+ const duration = worked();
131
+ const short = duration === null ? "" : formatWorkDuration(duration, t());
132
+ if (segment().phase === "stopped") return t().workedStopped({ duration: short });
133
+ if (segment().phase === "failed") return t().workedInterrupted({ duration: short });
134
+ return short ? t().workedFor({ duration: short }) : t().worked;
135
+ };
136
+ const ariaLabel = () => {
137
+ const phase = segment().phase;
138
+ if (isLive(phase)) return undefined;
139
+ const duration = worked();
140
+ return t().workedLabel({
141
+ duration: duration === null ? "" : formatWorkDuration(duration, t(), true),
142
+ steps: layout().steps ? steps() : "",
143
+ ending: phase === "stopped" || phase === "failed" ? phase : null,
144
+ });
145
+ };
146
+ const trailing = () => {
147
+ const duration = segment().duration();
148
+ if (working() && duration) {
149
+ return (
150
+ <span class="ai-turn-work__meta">
151
+ {formatWorkClock(duration.workedMs)}
152
+ <span class="ai-turn-work__live-steps"> · {steps()}</span>
153
+ </span>
154
+ );
155
+ }
156
+ if (waiting() && duration?.waitingMs !== null && duration?.waitingMs !== undefined) {
157
+ return (
158
+ <span class="ai-turn-work__meta">
159
+ {t().waitingSince({ duration: formatWorkDuration(duration.waitingMs, t()) })}
160
+ <span class="ai-turn-work__live-steps"> · {steps()}</span>
161
+ </span>
162
+ );
163
+ }
164
+ return layout().steps ? <span class="ai-turn-work__meta">{steps()}</span> : undefined;
165
+ };
166
+ return (
167
+ <AiToolActivity
168
+ blockId={`work:${segment().id}`}
169
+ class="ai-turn-work"
170
+ label={label()}
171
+ ariaLabel={ariaLabel()}
172
+ icon={waiting() ? "ti ti-hand-stop" : segment().phase === "stopped" ? "ti ti-player-stop" : "ti ti-route"}
173
+ tone={working() ? "ai" : "neutral"}
174
+ busy={working()}
175
+ trailing={trailing()}
176
+ renderBody={() => <AiWorkSteps blocks={layout().work} active={working()} />}
177
+ />
178
+ );
179
+ }
180
+
181
+ function ThoughtRow(props: { block: Extract<AiTurnBlock, { kind: "thinking" }>; busy: boolean }) {
182
+ const locale = useLocale();
183
+ const firstLine = () =>
184
+ props.block.text
185
+ .split("\n")
186
+ .map((line) => line.trim())
187
+ .find(Boolean) ?? "";
188
+ return (
189
+ <Show when={firstLine()} fallback={<Chat.Activity label={aiChatMessages(locale()).stepThinking} icon="ti ti-bulb" busy={props.busy} />}>
190
+ <AiToolActivity
191
+ blockId={props.block.id}
192
+ label={firstLine()}
193
+ icon="ti ti-bulb"
194
+ renderBody={() => <p class="ai-turn-steps__thought">{props.block.text.trim()}</p>}
195
+ />
196
+ </Show>
197
+ );
198
+ }
199
+
200
+ function WorkEntryRow(props: { entry: AiWorkEntry; busy: boolean }) {
201
+ const locale = useLocale();
202
+ return (
203
+ <Switch>
204
+ <Match when={props.entry.kind === "tool" && props.entry}>{(tool) => <CompactToolRow block={tool()} busy={props.busy} />}</Match>
205
+ <Match when={props.entry.kind === "thinking" && props.entry}>{(thought) => <ThoughtRow block={thought()} busy={props.busy} />}</Match>
206
+ <Match when={props.entry.kind === "compaction" && props.entry}>{(compaction) => <CompactionBlockView block={compaction()} />}</Match>
207
+ <Match when={props.entry.kind === "steer_applied"}>
208
+ <Chat.Activity label={aiChatMessages(locale()).steered} icon="ti ti-route" />
209
+ </Match>
210
+ </Switch>
211
+ );
212
+ }
213
+
214
+ function WorkGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
215
+ const locale = useLocale();
216
+ const tools = () => props.entries.filter((entry): entry is ToolBlock => entry.kind === "tool");
217
+ const last = () => props.entries.at(-1)!;
218
+ return (
219
+ <Show when={props.entries.length > 1} fallback={<WorkEntryRow entry={last()} busy={props.active} />}>
220
+ <AiToolActivity
221
+ blockId={`group:${props.id}`}
222
+ label={tools().length ? summarizeToolGroup(tools(), locale()) : aiChatMessages(locale()).reasoning}
223
+ description={countWorkSteps(props.entries, locale())}
224
+ icon="ti ti-stack-2"
225
+ busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
226
+ renderBody={() => (
227
+ <For each={props.entries}>
228
+ {(entry, index) => <WorkEntryRow entry={entry} busy={props.active && index() === props.entries.length - 1} />}
229
+ </For>
230
+ )}
231
+ />
232
+ </Show>
233
+ );
234
+ }
235
+
236
+ function WorkText(props: { text: string }) {
237
+ const locale = useLocale();
238
+ // Render Markdown only when the text itself changes, not on every update of the turn around it.
239
+ const text = createMemo(() => props.text);
240
+ const html = createMemo(() => markdown.renderSync(text(), { locale: locale() }));
241
+ return (
242
+ <div class="ai-turn-steps__text">
243
+ <AssistantMarkdownBlock html={html()} />
244
+ </div>
245
+ );
246
+ }
247
+
248
+ /** Expanded work: intermediate texts as quiet paragraphs, the steps between them as groups. One indent, one size. */
249
+ function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
250
+ const groups = createMemo(() => groupWorkBlocks(props.blocks));
251
+ const keyed = createMemo(
252
+ () => new Map(groups().map((group) => [group.kind === "text" ? `text:${group.block.id}` : `steps:${group.id}`, group])),
253
+ );
254
+ const keys = createMemo(() => [...keyed().keys()]);
255
+ return (
256
+ <div class="ai-turn-steps">
257
+ <For each={keys()}>
258
+ {(key, index) => {
259
+ const group = () => keyed().get(key);
260
+ const text = () => {
261
+ const value = group();
262
+ return value?.kind === "text" ? value.block.text : undefined;
263
+ };
264
+ const steps = () => {
265
+ const value = group();
266
+ return value?.kind === "steps" ? value : undefined;
267
+ };
268
+ return (
269
+ <Switch>
270
+ <Match when={text() !== undefined}>
271
+ <WorkText text={text()!} />
272
+ </Match>
273
+ <Match when={steps()}>
274
+ {(value) => <WorkGroup id={value().id} entries={value().entries} active={props.active && index() === keys().length - 1} />}
275
+ </Match>
276
+ </Switch>
277
+ );
278
+ }}
279
+ </For>
280
+ </div>
281
+ );
282
+ }
283
+
284
+ const resultRecord = (block: ToolBlock) => (isRecord(block.result) ? block.result : null);
285
+
286
+ /** The application's own short summary of a result, if it fits on one line. */
287
+ const resultSummary = (block: ToolBlock): string => {
288
+ const value = resultRecord(block)?.summary;
289
+ return typeof value === "string" && value.trim().length <= 500 ? value.trim() : "";
290
+ };
291
+
292
+ /** Links a result offers, limited to paths inside this Cloud. */
293
+ const resultLinks = (block: ToolBlock): Record<string, unknown>[] => {
294
+ const value = resultRecord(block)?.links;
295
+ return Array.isArray(value)
296
+ ? value.filter(
297
+ (link): link is Record<string, unknown> =>
298
+ isRecord(link) && typeof link.href === "string" && /^\/(?![\\/])[^\\\u0000-\u001f\u007f]*$/.test(link.href),
299
+ )
300
+ : [];
301
+ };
302
+
303
+ function ResultLinks(props: { links: Record<string, unknown>[] }) {
304
+ const locale = useLocale();
305
+ const t = () => aiChatMessages(locale());
306
+ const label = (link: Record<string, unknown>) =>
307
+ typeof link.title === "string" ? link.title : link.rel === "edit" ? t().edit : link.rel === "download" ? t().download : t().open;
308
+ return (
309
+ <span class="flex min-w-0 flex-wrap items-center justify-end gap-1">
310
+ <For each={props.links}>
311
+ {(link) => (
312
+ <ButtonLink
313
+ class="ai-chat-result-link"
314
+ href={String(link.href)}
315
+ target="_blank"
316
+ rel="noopener noreferrer"
317
+ size="xs"
318
+ variant="ghost"
319
+ >
320
+ {label(link)}
321
+ </ButtonLink>
322
+ )}
323
+ </For>
324
+ </span>
325
+ );
326
+ }
327
+
328
+ /** The summary and links of a table result, unless its receipt in place 4 already shows them. */
329
+ function CapabilityResultRow(props: { block: ToolBlock }) {
330
+ const block = () => props.block;
331
+ const summary = () => resultSummary(block());
332
+ const links = () => resultLinks(block());
333
+ return (
334
+ <Show when={summary() || links().length > 0}>
335
+ <Chat.Activity
336
+ icon={aiToolIcon(block().name, block().presentation?.appIcon)}
337
+ accent={block().presentation?.appAccent}
338
+ label={summary() || (block().presentation?.title ?? displayToolName(block().name))}
339
+ trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
340
+ />
341
+ </Show>
342
+ );
343
+ }
344
+
345
+ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; receipt: Accessor<boolean> }) {
346
+ const actions = useAiChatActions();
347
+ const block = () => props.result()?.block;
348
+ return (
349
+ <Show when={block()}>
350
+ {(current) => (
351
+ <Switch>
352
+ <Match when={current().name === "present"}>
353
+ <PresentToolBlock block={current()} />
354
+ </Match>
355
+ <Match when={current().name === "code_present"}>
356
+ {/* One host view per call: it reserves its frame while the call runs and keeps its session afterwards. */}
357
+ <Show keyed when={current().callId}>
358
+ {(_callId: string) =>
359
+ untrack(() =>
360
+ actions.renderCodePresentation?.(() => {
361
+ const latest = block();
362
+ return latest?.status === "completed" ? latest.result : undefined;
363
+ }),
364
+ )
365
+ }
366
+ </Show>
367
+ </Match>
368
+ <Match when={isCardToolName(current().name)}>
369
+ <CloudCardBlock args={current().args} />
370
+ </Match>
371
+ <Match when={true}>
372
+ <Show when={!props.receipt()}>
373
+ <CapabilityResultRow block={current()} />
374
+ </Show>
375
+ <CapabilityTablePreview result={current().result} label={current().presentation?.title ?? displayToolName(current().name)} />
376
+ </Match>
377
+ </Switch>
378
+ )}
379
+ </Show>
380
+ );
381
+ }
382
+
383
+ function AiReceipt(props: { action: AiTurnAction; stopped: boolean }) {
384
+ const locale = useLocale();
385
+ const t = () => aiChatMessages(locale());
386
+ const block = () => props.action.block;
387
+ const title = () => block().presentation?.title ?? displayToolName(block().name);
388
+ const links = () => (props.action.state === "done" ? resultLinks(block()) : []);
389
+ const label = () => {
390
+ const state = props.action.state;
391
+ if (state === "done") {
392
+ const summary = resultSummary(block());
393
+ if (summary) return summary;
394
+ // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
395
+ return block().presentation?.kind === "capability" ? t().receiptDone({ title: title() }) : t().receiptApproved({ title: title() });
396
+ }
397
+ if (state === "rejected") return t().receiptRejected({ title: title() });
398
+ if (state === "failed") return t().receiptFailed({ title: title() });
399
+ return t().receiptNotRun({ title: title(), stopped: props.stopped });
400
+ };
401
+ return (
402
+ <Chat.Activity
403
+ class="ai-turn-receipt"
404
+ icon={aiToolIcon(block().name, block().presentation?.appIcon)}
405
+ accent={block().presentation?.appAccent}
406
+ label={label()}
407
+ description={props.action.state === "failed" ? capabilityErrorDescription(block().result) : undefined}
408
+ trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
409
+ />
410
+ );
411
+ }
412
+
413
+ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; turnId: string; phase: AiTurnPhase }) {
414
+ const locale = useLocale();
415
+ let row!: HTMLDivElement;
416
+ // Set while focus is in this place, including after the focused control disappeared with the decided card.
417
+ let focusedHere = false;
418
+ const action = () => props.action();
419
+ const state = () => action()?.state;
420
+ // Screen readers hear a waiting approval once, as one short line instead of the whole card, without moving focus.
421
+ createEffect(
422
+ on(state, (next, previous) => {
423
+ const current = action();
424
+ if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
425
+ announce(
426
+ aiChatMessages(locale()).approvalNeeded({ title: current.block.presentation?.title ?? displayToolName(current.block.name) }),
427
+ );
428
+ }),
429
+ );
430
+ // A card the user decided becomes its receipt in place. Focus stays on that place without scrolling.
431
+ createEffect(
432
+ on(state, (next, previous) => {
433
+ if (previous !== "open" || next === "open" || !focusedHere || typeof document === "undefined") return;
434
+ const focused = document.activeElement;
435
+ if (!focused || focused === document.body || row.contains(focused)) row.focus({ preventScroll: true });
436
+ }),
437
+ );
438
+ return (
439
+ <div
440
+ ref={row}
441
+ class="ai-turn__action"
442
+ aria-live="off"
443
+ tabIndex={-1}
444
+ onFocusIn={() => {
445
+ focusedHere = true;
446
+ }}
447
+ onFocusOut={(event) => {
448
+ if (event.relatedTarget instanceof Node && !row.contains(event.relatedTarget)) focusedHere = false;
449
+ }}
450
+ >
451
+ <Show when={action()}>
452
+ {(current) => (
453
+ <Switch fallback={<AiReceipt action={current()} stopped={props.phase === "stopped"} />}>
454
+ <Match when={current().state === "open" && current().block.status === "awaiting_approval"}>
455
+ <ApprovalBlockView turnId={props.turnId} block={current().block} />
456
+ </Match>
457
+ <Match when={current().state === "open" || current().state === "interaction"}>
458
+ <Switch
459
+ fallback={
460
+ <Chat.Activity
461
+ label={current().block.presentation?.title ?? displayToolName(current().block.name)}
462
+ icon={aiToolIcon(current().block.name)}
463
+ busy={current().block.status === "awaiting_client"}
464
+ />
465
+ }
466
+ >
467
+ <Match when={isSurveyToolName(current().block.name)}>
468
+ <SurveyToolView turnId={props.turnId} block={current().block} active={isLive(props.phase)} />
469
+ </Match>
470
+ <Match when={current().block.name === "text_editor" || current().block.name === "cloud_text_editor"}>
471
+ <TextEditorToolView turnId={props.turnId} block={current().block} active={isLive(props.phase)} />
472
+ </Match>
473
+ </Switch>
474
+ </Match>
475
+ <Match when={current().state === "running"}>
476
+ <Chat.Activity
477
+ label={current().block.presentation?.title ?? displayToolName(current().block.name)}
478
+ icon={aiToolIcon(current().block.name, current().block.presentation?.appIcon)}
479
+ accent={current().block.presentation?.appAccent}
480
+ busy
481
+ />
482
+ </Match>
483
+ </Switch>
484
+ )}
485
+ </Show>
486
+ </div>
487
+ );
488
+ }
489
+
490
+ /**
491
+ * Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
492
+ * is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
493
+ */
494
+ function AiTurnText(props: {
495
+ blocks: Accessor<Extract<AiTurnBlock, { kind: "text" }>[]>;
496
+ live: Accessor<boolean>;
497
+ /** The turn has work steps, so its live text is a status rather than the answer itself. */
498
+ folding: Accessor<boolean>;
499
+ /** An earlier segment of a split turn: it stops being live while the turn goes on. */
500
+ earlier: Accessor<boolean>;
501
+ }) {
502
+ const locale = useLocale();
503
+ const status = () => props.live() && props.folding();
504
+ const source = createMemo(() =>
505
+ props
506
+ .blocks()
507
+ .map((block) => block.text)
508
+ .join("\n\n"),
509
+ );
510
+ const html = createMemo(() => markdown.renderSync(source(), { locale: locale() }));
511
+ const shownId = createMemo(() => props.blocks()[0]?.id);
512
+ const [minHeight, setMinHeight] = createSignal<number | null>(null);
513
+ let element!: HTMLDivElement;
514
+ let lastHeight = 0;
515
+ onMount(() => {
516
+ if (typeof ResizeObserver === "undefined") return;
517
+ const observer = new ResizeObserver(() => {
518
+ lastHeight = element.offsetHeight;
519
+ });
520
+ observer.observe(element);
521
+ onCleanup(() => observer.disconnect());
522
+ });
523
+ createEffect(
524
+ on(
525
+ shownId,
526
+ () => {
527
+ if (props.live() && lastHeight > 0) setMinHeight((current) => Math.max(current ?? 0, lastHeight));
528
+ },
529
+ { defer: true },
530
+ ),
531
+ );
532
+ createEffect(() => {
533
+ if (!props.live()) setMinHeight(null);
534
+ });
535
+ let shown = { id: untrack(shownId), text: untrack(source) };
536
+ createEffect(
537
+ on([shownId, source], ([id, text]) => {
538
+ if (id !== shown.id && status() && shown.text.trim()) announce(shown.text);
539
+ shown = { id, text };
540
+ }),
541
+ );
542
+ createEffect(
543
+ on(props.live, (live, wasLive) => {
544
+ if (wasLive === true && !live && untrack(props.folding) && !untrack(props.earlier)) announce(aiChatMessages(locale()).answerReady);
545
+ }),
546
+ );
547
+ return (
548
+ <div
549
+ ref={element}
550
+ class="ai-turn__text"
551
+ aria-live={status() ? "off" : undefined}
552
+ style={minHeight() ? { "min-height": `${minHeight()}px` } : undefined}
553
+ >
554
+ <AssistantMarkdownBlock html={html()} />
555
+ </div>
556
+ );
557
+ }
558
+
559
+ /**
560
+ * One assistant turn segment in four fixed places: the work line, results, the newest text, and actions. Results and
561
+ * actions are keyed by their call, so a running Studio preview or a decided approval keeps its element when the live
562
+ * turn becomes history.
563
+ */
564
+ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosureState: AiToolDisclosureState }): JSX.Element {
565
+ const locale = useLocale();
566
+ const chatActions = useAiChatActions();
567
+ const layout = () => props.segment().layout;
568
+ const results = createMemo(() => new Map(layout().results.map((result) => [result.id, result])));
569
+ const resultIds = createMemo(() => [...results().keys()]);
570
+ const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
571
+ const actionIds = createMemo(() => [...actions().keys()]);
572
+ const live = () => isLive(props.segment().phase);
573
+ return (
574
+ <AiToolDisclosureProvider state={props.disclosureState}>
575
+ <div class="ai-turn">
576
+ <Show when={props.segment().scheduledTask}>
577
+ {(task) => (
578
+ <Button
579
+ variant="ghost"
580
+ size="xs"
581
+ class="self-start"
582
+ onClick={() => chatActions.onOpenScheduledTaskRun?.(task().taskId, task().occurrenceId)}
583
+ disabled={!chatActions.onOpenScheduledTaskRun}
584
+ >
585
+ <i class="ti ti-calendar-time" aria-hidden="true" /> {aiChatMessages(locale()).backgroundRun}
586
+ </Button>
587
+ )}
588
+ </Show>
589
+ <Show when={layout().showWork}>
590
+ {/* The live label and clock change every second; the conversation log must not read them out. */}
591
+ <div class="ai-turn__work" aria-live="off">
592
+ <AiWorkLine segment={props.segment} />
593
+ </div>
594
+ </Show>
595
+ <For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
596
+ <Show when={layout().text.length > 0}>
597
+ <AiTurnText blocks={() => layout().text} live={live} folding={() => layout().steps > 0} earlier={() => props.segment().earlier} />
598
+ </Show>
599
+ <For each={actionIds()}>
600
+ {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
601
+ </For>
602
+ <Show when={props.segment().retrying}>
603
+ {/* Calm by design: no busy sweep while the model call waits for its retry. */}
604
+ <Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
605
+ </Show>
606
+ </div>
607
+ </AiToolDisclosureProvider>
608
+ );
609
+ }