@k2b/cloud 0.29.0 → 0.29.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -55,6 +55,7 @@
55
55
  "./services": "./src/services/index.ts",
56
56
  "./services/image-metadata": "./src/services/image-metadata.ts",
57
57
  "./services/document-extraction": "./src/services/document-extraction.ts",
58
+ "./services/outbox": "./src/services/outbox.ts",
58
59
  "./services/ipa/service-account": null,
59
60
  "./services/*": "./src/services/*",
60
61
  "./ai": "./src/ai/index.ts",
@@ -91,7 +92,7 @@
91
92
  },
92
93
  "scripts": {
93
94
  "test": "bun test --isolate --path-ignore-patterns '**/*.behavior.test.*'",
94
- "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
95
+ "typecheck": "tsc --checkers 2 -p tsconfig.json --noEmit --pretty false"
95
96
  },
96
97
  "dependencies": {
97
98
  "@firecrawl/anydoc": "0.2.4",
@@ -102,7 +103,7 @@
102
103
  "@tailwindcss/typography": "0.5.20",
103
104
  "@k2b/nessi": "0.17.0",
104
105
  "@k2b/ssr": "0.15.0",
105
- "@k2b/ui": "0.19.0",
106
+ "@k2b/ui": "0.20.0",
106
107
  "@k2b/stdlib": "0.28.0",
107
108
  "@k2b/sync": "7.0.0",
108
109
  "@nats-io/jetstream": "3.4.0",
@@ -183,7 +183,14 @@ function ToolResultDisclosure(props: {
183
183
  );
184
184
  }
185
185
 
186
- export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
186
+ /** A decision the server accepted, for the call it answered. */
187
+ export type AiApprovalDecision = { callId: string; approved: boolean };
188
+
189
+ /**
190
+ * The pending approval: a calm tinted card that says what will happen, with its fields and the decision. `onDecided`
191
+ * hears an accepted decision with the call it answered, so the turn can show its receipt in the card's place right away.
192
+ */
193
+ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onDecided?: (decision: AiApprovalDecision) => void }) {
187
194
  const actions = useAiChatActions();
188
195
  const locale = useLocale();
189
196
  const t = () => aiChatMessages(locale());
@@ -192,15 +199,22 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
192
199
  const actionDisabled = () => actions.actionDisabled?.() ?? false;
193
200
  const [submitted, setSubmitted] = createSignal(false);
194
201
  const [detailsOpen, setDetailsOpen] = createSignal(false);
202
+ // The call this card answered: a later approval for the same block may already have arrived when the server accepts.
203
+ let decision: AiApprovalDecision | null = null;
195
204
  const approval = mutation.create<void, { approved: boolean; remember?: "always" }>({
196
205
  mutation: async (input) => {
197
206
  if (!actions.onApproval) throw new Error("Approval is unavailable.");
198
207
  await actions.onApproval(request(), input);
199
208
  },
200
- onSuccess: () => setSubmitted(true),
209
+ onSuccess: () => {
210
+ setSubmitted(true);
211
+ if (decision) props.onDecided?.(decision);
212
+ },
201
213
  });
202
214
  const submit = (input: { approved: boolean; remember?: "always" }) => {
203
- if (!actionDisabled() && !approval.loading()) void approval.mutate(input);
215
+ if (actionDisabled() || approval.loading()) return;
216
+ decision = { callId: props.block.callId, approved: input.approved };
217
+ void approval.mutate(input);
204
218
  };
205
219
  const title = () => props.block.presentation?.title ?? displayToolName(props.block.name, locale());
206
220
  const ownerName = () => props.block.presentation?.appName ?? t().assistant;
@@ -239,199 +253,166 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
239
253
  download: t().download,
240
254
  })[rel];
241
255
  const detailData = () => (isStructuredDataValue(props.block.args) ? props.block.args : undefined);
242
- const appAccent = () => props.block.presentation?.appAccent;
243
256
  const detailsId = `approval-details-${props.block.callId}`;
244
257
  return (
245
- <div class="w-full">
246
- <section
247
- class={`w-full overflow-hidden rounded-xl border border-[var(--k2b-border)] bg-[var(--k2b-surface)] text-sm text-primary ${appAccent() ? "app-accent-scope" : ""}`}
248
- style={{ "--app-accent": appAccent() }}
249
- aria-label={t().approvalRequired({ title: title() })}
250
- >
251
- <div class="p-4">
252
- <div class="flex min-w-0 items-center gap-3">
253
- <span
254
- class="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-base leading-none"
255
- style={{
256
- color: appAccent() ? "var(--ui-app-accent-text)" : "var(--k2b-ai-accent)",
257
- background: appAccent()
258
- ? "color-mix(in srgb, var(--app-accent) 10%, var(--k2b-surface))"
259
- : "color-mix(in srgb, var(--k2b-ai-accent) 10%, var(--k2b-surface))",
260
- }}
261
- aria-hidden="true"
262
- >
263
- <i class={`${aiToolIcon(props.block.name, props.block.presentation?.appIcon)} leading-none`} />
264
- </span>
265
- <div class="min-w-0">
266
- <h3 class="truncate text-sm font-semibold leading-5 text-primary">
267
- {ownerName()} · {title()}
268
- </h3>
269
- <p class="text-xs text-dimmed">{t().action}</p>
270
- </div>
271
- </div>
272
- <Show when={reviewLines().length > 0}>
273
- <div class="mt-4 flex flex-col gap-1 text-xs leading-5 text-secondary">
274
- <For each={reviewLines()}>
275
- {(line) => (
276
- <p class="whitespace-pre-wrap">
277
- <Show when={line.label}>{(label) => <strong class="font-semibold text-primary">{label()}: </strong>}</Show>
278
- {line.value}
279
- </p>
280
- )}
281
- </For>
282
- </div>
283
- </Show>
284
- <Show when={props.block.approval?.review}>
285
- <div class="mt-4 flex flex-col gap-4 text-xs leading-5 text-secondary">
286
- <p class="whitespace-pre-wrap">{description()}</p>
287
- <Show when={inlineReviewDetails().length > 0}>
288
- <dl class="grid gap-x-5 gap-y-1.5 sm:grid-cols-[max-content_minmax(0,1fr)]">
289
- <For each={inlineReviewDetails()}>
290
- {(detail) => (
291
- <>
292
- <dt class="font-semibold text-primary">{detail.label}</dt>
293
- <dd class="min-w-0 whitespace-pre-wrap break-words">
294
- <ReviewDetailValue detail={detail} />
295
- </dd>
296
- </>
297
- )}
298
- </For>
299
- </dl>
258
+ <section class="ai-approval" aria-label={t().approvalRequired({ title: title() })}>
259
+ <div class="ai-approval__head">
260
+ <i class={`ai-approval__icon ${aiToolIcon(props.block.name, props.block.presentation?.appIcon)}`} aria-hidden="true" />
261
+ <div class="min-w-0">
262
+ <h3 class="ai-approval__title">
263
+ {ownerName()} · {title()}
264
+ </h3>
265
+ <p class="ai-approval__sub">{t().approvalRunsAfter}</p>
266
+ </div>
267
+ </div>
268
+ <Show when={reviewLines().length > 0}>
269
+ <div class="ai-approval__text">
270
+ <For each={reviewLines()}>
271
+ {(line) => (
272
+ <p class="whitespace-pre-wrap">
273
+ <Show when={line.label}>{(label) => <strong class="ai-approval__label">{label()}: </strong>}</Show>
274
+ {line.value}
275
+ </p>
276
+ )}
277
+ </For>
278
+ </div>
279
+ </Show>
280
+ <Show when={props.block.approval?.review}>
281
+ <p class="ai-approval__text whitespace-pre-wrap">{description()}</p>
282
+ <Show when={inlineReviewDetails().length > 0}>
283
+ <dl class="ai-approval__fields">
284
+ <For each={inlineReviewDetails()}>
285
+ {(detail) => (
286
+ <>
287
+ <dt>{detail.label}</dt>
288
+ <dd class="whitespace-pre-wrap">
289
+ <ReviewDetailValue detail={detail} />
290
+ </dd>
291
+ </>
292
+ )}
293
+ </For>
294
+ </dl>
295
+ </Show>
296
+ <For each={blockReviewDetails()}>
297
+ {(detail) => (
298
+ <section class="ai-approval__text min-w-0" aria-label={detail.label}>
299
+ <h4 class="ai-approval__label mb-1">{detail.label}</h4>
300
+ <Show
301
+ when={detail.format === "markdown"}
302
+ fallback={
303
+ <pre
304
+ class="ai-approval__preview whitespace-pre-wrap break-words font-sans"
305
+ role="region"
306
+ tabIndex={0}
307
+ aria-label={t().contentOf({ label: detail.label })}
308
+ >
309
+ <ReviewDetailValue detail={detail} />
310
+ </pre>
311
+ }
312
+ >
313
+ <div class="ai-approval__preview" role="region" tabIndex={0} aria-label={detail.label}>
314
+ <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
315
+ </div>
300
316
  </Show>
301
- <For each={blockReviewDetails()}>
302
- {(detail) => (
303
- <section class="min-w-0" aria-label={detail.label}>
304
- <h4 class="mb-1.5 font-semibold text-primary">{detail.label}</h4>
305
- <Show
306
- when={detail.format === "markdown"}
307
- fallback={
308
- <pre
309
- class="max-h-72 overflow-auto whitespace-pre-wrap break-words pr-2 font-sans text-xs leading-5 text-secondary"
310
- role="region"
311
- tabIndex={0}
312
- aria-label={t().contentOf({ label: detail.label })}
313
- >
314
- <ReviewDetailValue detail={detail} />
315
- </pre>
316
- }
317
- >
318
- <div
319
- class="-mx-2 max-h-72 overflow-auto px-2 text-xs text-secondary"
320
- role="region"
321
- tabIndex={0}
322
- aria-label={detail.label}
323
- >
324
- <MarkdownView class="cloud-ai-approval-detail" markdown={detail.value} headingScale="compact" allowImages={false} />
325
- </div>
326
- </Show>
327
- </section>
328
- )}
329
- </For>
330
- </div>
317
+ </section>
318
+ )}
319
+ </For>
320
+ </Show>
321
+ <Show when={detailsOpen()}>
322
+ <div id={detailsId} class="w-full min-w-0" role="region" aria-label={t().detailsFor({ title: title() })}>
323
+ <Show when={detailData()} fallback={<pre class="ai-approval__preview text-[11px]">{jsonPreview(props.block.args)}</pre>}>
324
+ {(data) => <StructuredDataPreview data={data()} class="w-full" />}
331
325
  </Show>
332
326
  </div>
333
- {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
334
- <footer class="flex min-h-12 flex-wrap items-center gap-2 px-4 pb-3" data-ai-approval-footer>
335
- {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
336
- <span class="k2b-sr-only" role="status" aria-live="polite">
337
- {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
338
- </span>
339
- <Show when={reviewLinks().length > 0}>
340
- <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
341
- <For each={reviewLinks()}>
342
- {(link) => (
343
- <ButtonLink href={link.href} target="_blank" rel="noopener noreferrer" size="xs" variant="ghost">
344
- {link.title ?? reviewLinkTitle(link.rel)}
345
- </ButtonLink>
346
- )}
347
- </For>
348
- </nav>
349
- </Show>
350
- <Show when={approval.error()}>
351
- <p class="text-xs text-red-700 dark:text-red-300" role="alert">
352
- {t().approvalFailed}
353
- </p>
354
- </Show>
355
- <div class="ml-auto shrink-0">
356
- <Show
357
- when={pending()}
358
- fallback={
359
- <span class="text-xs font-medium text-secondary">
360
- {title()} · {props.block.status === "rejected" ? t().rejected : t().approved}
361
- </span>
362
- }
363
- >
364
- <Show when={actions.onApproval} fallback={<span class="text-xs font-medium text-secondary">{t().approvalUnavailable}</span>}>
327
+ </Show>
328
+ {/* The decision continues the card below its content: spacing, not a line or a band, sets it apart. */}
329
+ <footer class="ai-approval__foot" data-ai-approval-footer>
330
+ {/* Present from the start, so screen readers announce progress after the decision buttons leave. */}
331
+ <span class="k2b-sr-only" role="status" aria-live="polite">
332
+ {approval.loading() ? t().submitting : submitted() ? t().submitted : ""}
333
+ </span>
334
+ <Show when={reviewLinks().length > 0}>
335
+ <nav class="flex flex-wrap gap-1" aria-label={t().linksFor({ title: title() })}>
336
+ <For each={reviewLinks()}>
337
+ {(link) => (
338
+ <ButtonLink href={link.href} target="_blank" rel="noopener noreferrer" size="xs" variant="ghost">
339
+ {link.title ?? reviewLinkTitle(link.rel)}
340
+ </ButtonLink>
341
+ )}
342
+ </For>
343
+ </nav>
344
+ </Show>
345
+ <Show when={approval.error()}>
346
+ <p class="text-xs text-red-700 dark:text-red-300" role="alert">
347
+ {t().approvalFailed}
348
+ </p>
349
+ </Show>
350
+ <div class="ai-approval__actions">
351
+ <Show
352
+ when={pending()}
353
+ fallback={
354
+ <span class="ai-approval__state">
355
+ {title()} · {props.block.status === "rejected" ? t().rejected : t().approved}
356
+ </span>
357
+ }
358
+ >
359
+ <Show when={actions.onApproval} fallback={<span class="ai-approval__state">{t().approvalUnavailable}</span>}>
360
+ <Show
361
+ when={!actionDisabled()}
362
+ fallback={
363
+ <span class="ai-approval__state">
364
+ <i class="ti ti-player-stop" aria-hidden="true" />
365
+ {t().stoppingResponse}
366
+ </span>
367
+ }
368
+ >
365
369
  <Show
366
- when={!actionDisabled()}
370
+ when={!submitted()}
367
371
  fallback={
368
- <span class="inline-flex items-center gap-1 text-xs font-medium text-secondary">
369
- <i class="ti ti-player-stop" aria-hidden="true" />
370
- {t().stoppingResponse}
372
+ <span class="ai-approval__state">
373
+ <i class="ti ti-check" aria-hidden="true" />
374
+ {t().submitted}
371
375
  </span>
372
376
  }
373
377
  >
374
- <Show
375
- when={!approval.loading() && !submitted()}
376
- fallback={
377
- <span class="inline-flex items-center gap-1 text-xs font-medium text-secondary">
378
- <i class={`ti ${submitted() ? "ti-check" : "ti-loader-2 animate-spin"}`} aria-hidden="true" />
379
- {submitted() ? t().submitted : t().submitting}
380
- </span>
381
- }
378
+ {/* The buttons stay while the decision is sent, so the card does not change its height before it
379
+ becomes its receipt. */}
380
+ <Button size="sm" variant="ghost" disabled={approval.loading()} onClick={() => submit({ approved: false })}>
381
+ {t().reject}
382
+ </Button>
383
+ <SplitButton
384
+ size="sm"
385
+ variant="ai"
386
+ loading={approval.loading()}
387
+ onClick={() => submit({ approved: true })}
388
+ menuLabel={t().moreOptions({ title: title() })}
389
+ menuPosition="bottom-right"
390
+ items={[
391
+ {
392
+ label: detailsOpen() ? t().hideDetails : t().details,
393
+ icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
394
+ action: () => setDetailsOpen((open) => !open),
395
+ },
396
+ ...(props.block.approval?.allowAlways
397
+ ? [
398
+ {
399
+ label: t().alwaysApprove,
400
+ icon: "ti ti-shield-check",
401
+ action: () => submit({ approved: true, remember: "always" }),
402
+ },
403
+ ]
404
+ : []),
405
+ ]}
382
406
  >
383
- <div class="flex flex-wrap justify-end gap-1">
384
- <Button size="xs" variant="ghost" onClick={() => submit({ approved: false })}>
385
- {t().reject}
386
- </Button>
387
- <SplitButton
388
- size="xs"
389
- variant="ai"
390
- onClick={() => submit({ approved: true })}
391
- menuLabel={t().moreOptions({ title: title() })}
392
- menuPosition="bottom-right"
393
- items={[
394
- {
395
- label: detailsOpen() ? t().hideDetails : t().details,
396
- icon: detailsOpen() ? "ti ti-eye-off" : "ti ti-eye",
397
- action: () => setDetailsOpen((open) => !open),
398
- },
399
- ...(props.block.approval?.allowAlways
400
- ? [
401
- {
402
- label: t().alwaysApprove,
403
- icon: "ti ti-shield-check",
404
- action: () => submit({ approved: true, remember: "always" }),
405
- },
406
- ]
407
- : []),
408
- ]}
409
- >
410
- {title()}
411
- </SplitButton>
412
- </div>
413
- </Show>
407
+ {title()}
408
+ </SplitButton>
414
409
  </Show>
415
410
  </Show>
416
411
  </Show>
417
- </div>
418
- </footer>
419
- </section>
420
- <Show when={detailsOpen()}>
421
- <div id={detailsId} class="mt-2 w-full" role="region" aria-label={t().detailsFor({ title: title() })}>
422
- <Show
423
- when={detailData()}
424
- fallback={
425
- <pre class="max-h-40 overflow-auto rounded-md bg-white/55 p-2 text-[11px] text-primary dark:bg-black/20">
426
- {jsonPreview(props.block.args)}
427
- </pre>
428
- }
429
- >
430
- {(data) => <StructuredDataPreview data={data()} class="w-full" />}
431
412
  </Show>
432
413
  </div>
433
- </Show>
434
- </div>
414
+ </footer>
415
+ </section>
435
416
  );
436
417
  }
437
418
 
@@ -849,6 +830,7 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
849
830
  .join(" · ")}
850
831
  icon={icon()}
851
832
  busy={props.busy && props.block.status === "running"}
833
+ bodyInset={false}
852
834
  renderBody={() => (
853
835
  <div class="flex min-w-0 flex-col gap-2">
854
836
  <div
@@ -11,15 +11,18 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
11
11
  * controller does. `window.emit(event)` applies one event,
12
12
  * `window.steer(text)` appends a pending steer as the controller does,
13
13
  * `window.reconnect(on)` sets whether the stream reconnects,
14
- * `window.continued` lists the messages that **Continue** sent, and
14
+ * `window.continued` lists the messages that **Continue** sent,
15
+ * `window.approvals` lists the approval decisions, and
15
16
  * `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
16
17
  */
17
18
  const [state, setState] = createStore(emptyProjection());
18
19
  const [reconnecting, setReconnecting] = createSignal(false);
19
20
  const continued: string[] = [];
20
21
  const opened: string[] = [];
22
+ const approvals: unknown[] = [];
21
23
  Object.assign(window, {
22
24
  continued,
25
+ approvals,
23
26
  opened,
24
27
  reconnect: (on: boolean) => setReconnecting(on),
25
28
  emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
@@ -38,6 +41,7 @@ render(
38
41
  <AiChatActionsProvider
39
42
  actions={{
40
43
  onContinueTurn: (message) => void continued.push(message),
44
+ onApproval: async (request, input) => void approvals.push({ callId: request.callId, ...input }),
41
45
  fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
42
46
  onOpenFile: (path) => void opened.push(path),
43
47
  }}
@@ -95,22 +95,12 @@ const messages = i18n.define({
95
95
  checkWarnings: ({ count }: { count: number }) => (count === 1 ? "1 warning" : `${count} warnings`),
96
96
  openImage: ({ name }: { name: string }) => `Open image ${name}`,
97
97
  steered: "Conversation steered",
98
- reasoning: "Reasoning",
99
- groupPlan: "Updated working plan",
100
- groupCode: "Worked with code",
101
- groupCloud: "Used Cloud integration",
102
- groupReadFiles: "Read files",
103
- groupWriteFiles: "Wrote files",
104
- groupPdf: "Created PDFs",
105
98
  groupLoad: "Loaded tools and guidance",
106
- groupWeb: "Searched the web",
107
- groupMemory: "Used memory",
108
- groupDelivered: "Delivered results",
109
- groupTools: "Used tools",
110
99
  groupFailed: ({ count }: { count: number }) => `${count} failed`,
111
100
  groupRejected: ({ count }: { count: number }) => `${count} rejected`,
112
101
  receiptDone: ({ title }: { title: string }) => `${title} done`,
113
102
  receiptApproved: ({ title }: { title: string }) => `Approved: ${title}`,
103
+ receiptRunning: ({ title }: { title: string }) => `Running: ${title}`,
114
104
  approvalNeeded: ({ title }: { title: string }) => `Approval needed: ${title}`,
115
105
  answerReady: "Answer ready",
116
106
  receiptRejected: ({ title }: { title: string }) => `Rejected: ${title}`,
@@ -121,7 +111,7 @@ const messages = i18n.define({
121
111
  streamAccessDenied: "You no longer have access to this chat.",
122
112
  streamNotFound: "This chat is no longer available.",
123
113
  assistant: "Assistant",
124
- action: "Action",
114
+ approvalRunsAfter: "Runs only after you approve it",
125
115
  approvalRequired: ({ title }: { title: string }) => `Approval required: ${title}`,
126
116
  reject: "Reject",
127
117
  approved: "Approved",
@@ -393,22 +383,12 @@ const messages = i18n.define({
393
383
  checkWarnings: ({ count }) => (count === 1 ? "1 Warnung" : `${count} Warnungen`),
394
384
  openImage: ({ name }) => `Bild ${name} öffnen`,
395
385
  steered: "Richtung geändert",
396
- reasoning: "Überlegungen",
397
- groupPlan: "Arbeitsplan aktualisiert",
398
- groupCode: "Mit Code gearbeitet",
399
- groupCloud: "Cloud-Integration verwendet",
400
- groupReadFiles: "Dateien gelesen",
401
- groupWriteFiles: "Dateien geschrieben",
402
- groupPdf: "PDFs erstellt",
403
386
  groupLoad: "Werkzeuge und Wissen geladen",
404
- groupWeb: "Im Web recherchiert",
405
- groupMemory: "Gedächtnis genutzt",
406
- groupDelivered: "Ergebnisse bereitgestellt",
407
- groupTools: "Werkzeuge verwendet",
408
387
  groupFailed: ({ count }) => `${count} fehlgeschlagen`,
409
388
  groupRejected: ({ count }) => `${count} abgelehnt`,
410
389
  receiptDone: ({ title }) => `${title} erledigt`,
411
390
  receiptApproved: ({ title }) => `Freigegeben: ${title}`,
391
+ receiptRunning: ({ title }) => `Wird ausgeführt: ${title}`,
412
392
  approvalNeeded: ({ title }) => `Freigabe nötig: ${title}`,
413
393
  answerReady: "Antwort fertig",
414
394
  receiptRejected: ({ title }) => `Abgelehnt: ${title}`,
@@ -419,7 +399,7 @@ const messages = i18n.define({
419
399
  streamAccessDenied: "Du hast keinen Zugriff mehr auf diesen Chat.",
420
400
  streamNotFound: "Dieser Chat ist nicht mehr verfügbar.",
421
401
  assistant: "Assistent",
422
- action: "Aktion",
402
+ approvalRunsAfter: "Wird erst nach deiner Freigabe ausgeführt",
423
403
  approvalRequired: ({ title }) => `Freigabe erforderlich: ${title}`,
424
404
  reject: "Ablehnen",
425
405
  approved: "Freigegeben",
@@ -1,28 +1,35 @@
1
1
  import { Chat, type ChatActivityProps } from "@k2b/ui";
2
- import { createContext, type JSX, splitProps, useContext } from "solid-js";
2
+ import { createContext, createSignal, type JSX, type Signal, splitProps, useContext } from "solid-js";
3
3
 
4
4
  export type AiToolDisclosureState = {
5
5
  get: (blockId: string) => boolean | undefined;
6
6
  set: (blockId: string, open: boolean) => void;
7
7
  };
8
8
 
9
+ /**
10
+ * Disclosure choices by block id. Reactive, so a disclosure that its reader opens renders its body at once, also when
11
+ * nothing else in the turn changes; choices survive a remount and, in session storage, a reload.
12
+ */
9
13
  export const createAiToolDisclosureState = (): AiToolDisclosureState => {
10
- const openByBlockId = new Map<string, boolean>();
14
+ const signals = new Map<string, Signal<boolean | undefined>>();
11
15
  const storageKey = (blockId: string) => `cloud.ai.tool-disclosure:${blockId}`;
16
+ const signal = (blockId: string): Signal<boolean | undefined> => {
17
+ const existing = signals.get(blockId);
18
+ if (existing) return existing;
19
+ let saved: boolean | undefined;
20
+ try {
21
+ if (window.sessionStorage.getItem(storageKey(blockId)) === "open") saved = true;
22
+ } catch {
23
+ /* SSR and disabled storage keep the in-memory state. */
24
+ }
25
+ const created = createSignal(saved);
26
+ signals.set(blockId, created);
27
+ return created;
28
+ };
12
29
  return {
13
- get: (blockId) => {
14
- if (!openByBlockId.has(blockId)) {
15
- try {
16
- const saved = window.sessionStorage.getItem(storageKey(blockId));
17
- if (saved === "open") openByBlockId.set(blockId, true);
18
- } catch {
19
- /* SSR and disabled storage keep the in-memory state. */
20
- }
21
- }
22
- return openByBlockId.get(blockId);
23
- },
30
+ get: (blockId) => signal(blockId)[0](),
24
31
  set: (blockId, open) => {
25
- openByBlockId.set(blockId, open);
32
+ signal(blockId)[1](open);
26
33
  try {
27
34
  if (open) window.sessionStorage.setItem(storageKey(blockId), "open");
28
35
  else window.sessionStorage.removeItem(storageKey(blockId));