@adea-ai/ui 0.67.2 → 0.68.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.
Files changed (30) hide show
  1. package/README.md +58 -0
  2. package/dist/NOTICE +34 -0
  3. package/dist/components/conversation/index.d.ts +1 -0
  4. package/dist/components/conversation/index.d.ts.map +1 -1
  5. package/dist/components/conversation/index.js +2 -1
  6. package/dist/components/conversation/transcript-composition.d.ts +66 -0
  7. package/dist/components/conversation/transcript-composition.d.ts.map +1 -0
  8. package/dist/components/conversation/transcript-composition.js +395 -0
  9. package/dist/components/conversation/transcript-composition.js.map +1 -0
  10. package/dist/components/conversation/transcript-folding.d.ts +22 -0
  11. package/dist/components/conversation/transcript-folding.d.ts.map +1 -0
  12. package/dist/components/conversation/transcript-folding.js +43 -0
  13. package/dist/components/conversation/transcript-folding.js.map +1 -0
  14. package/dist/components/conversation/transcript-grouping.d.ts +54 -0
  15. package/dist/components/conversation/transcript-grouping.d.ts.map +1 -0
  16. package/dist/components/conversation/transcript-grouping.js +155 -0
  17. package/dist/components/conversation/transcript-grouping.js.map +1 -0
  18. package/dist/index.js +2 -1
  19. package/dist/r/conversation.json +15 -0
  20. package/dist/r/registry.json +15 -0
  21. package/dist/r/src/components/conversation/index.ts +17 -0
  22. package/dist/r/src/components/conversation/transcript-composition.tsx +555 -0
  23. package/dist/r/src/components/conversation/transcript-folding.ts +61 -0
  24. package/dist/r/src/components/conversation/transcript-grouping.ts +204 -0
  25. package/package.json +8 -1
  26. package/registry.json +15 -0
  27. package/src/components/conversation/index.ts +17 -0
  28. package/src/components/conversation/transcript-composition.tsx +555 -0
  29. package/src/components/conversation/transcript-folding.ts +61 -0
  30. package/src/components/conversation/transcript-grouping.ts +204 -0
@@ -0,0 +1,555 @@
1
+ import type { Accessor, Component, ComponentProps } from 'solid-js'
2
+ import {
3
+ For,
4
+ Show,
5
+ createComponent,
6
+ createMemo,
7
+ createSignal,
8
+ createUniqueId,
9
+ splitProps,
10
+ } from 'solid-js'
11
+ import { ChevronDown } from 'lucide-solid'
12
+ import { cn } from '../../lib/utils'
13
+ import { Button } from '../ui/button/button'
14
+ import {
15
+ applyTranscriptRunningState,
16
+ createTranscriptGrouper,
17
+ type TranscriptDisplayItem,
18
+ type TranscriptRow,
19
+ type TranscriptTurnItem,
20
+ } from './transcript-grouping'
21
+ import {
22
+ planTranscriptFold,
23
+ type TranscriptFoldMode,
24
+ type TranscriptMounting,
25
+ } from './transcript-folding'
26
+
27
+ export type TranscriptFoldState = 'default' | 'collapsed' | 'expanded'
28
+ export type TranscriptDisplayKind = 'single' | 'group' | 'turn'
29
+
30
+ export type {
31
+ GroupedTranscript,
32
+ TranscriptDisplayItem,
33
+ TranscriptRow,
34
+ TranscriptTurnItem,
35
+ } from './transcript-grouping'
36
+ export type {
37
+ TranscriptFoldMode,
38
+ TranscriptFoldPlan,
39
+ TranscriptMounting,
40
+ } from './transcript-folding'
41
+
42
+ export type TranscriptCompositionFold = Readonly<{
43
+ /** Stable host persistence key. Use with `transcriptDisclosureKey` to seed a store. */
44
+ key: string
45
+ mode: TranscriptFoldMode
46
+ mounting: TranscriptMounting
47
+ /** Distinct host-classified request IDs in this contiguous fold segment. */
48
+ callCount: number
49
+ }>
50
+
51
+ export type TranscriptCompositionRowContext = Readonly<{
52
+ /** Original index in the caller's row array, including rows hidden by the host. */
53
+ index: number
54
+ /** The row's current position in the generic display composition. */
55
+ displayKind: TranscriptDisplayKind
56
+ group?: Readonly<{ index: number; size: number }>
57
+ turn?: Readonly<{
58
+ /** Derived from the first stable row ID; presentation bookkeeping only. */
59
+ id: string
60
+ /** A presentation state, never proof of successful runtime completion. */
61
+ complete: boolean
62
+ interim: boolean
63
+ }>
64
+ fold?: TranscriptCompositionFold
65
+ }>
66
+
67
+ export type TranscriptCompositionRendererProps<T> = Readonly<{
68
+ /** Reactive Solid prop; a row component keeps its DOM owner stable as values update. */
69
+ row: TranscriptRow<T>
70
+ context: TranscriptCompositionRowContext
71
+ }>
72
+
73
+ export type TranscriptCompositionProps<T> = Omit<ComponentProps<'div'>, 'children'> & {
74
+ /** Ordered, already redacted rows with host-supplied semantic classifications. */
75
+ rows: readonly TranscriptRow<T>[]
76
+ /** Render one host row. Permission, session, event and protocol authority stay outside this component. */
77
+ renderRow: Component<TranscriptCompositionRendererProps<T>>
78
+ /** The host's current run state. Live rows stay mounted and expanded until settled. */
79
+ running?: boolean
80
+ /** Reset scope for row identity and disclosure. It is not inferred from display content. */
81
+ resetKey?: string | number | null
82
+ /** Default settled fold policy. `tools` keeps prose visible; `prose` retains a proven conclusion. */
83
+ foldMode?: TranscriptFoldMode
84
+ /** Global command state. `collapsed` keeps hidden content mounted; prose folds only before a host-marked conclusion. */
85
+ foldState?: TranscriptFoldState
86
+ onFoldStateChange?: (state: TranscriptFoldState) => void
87
+ /** Optional controlled disclosure map keyed by `transcriptDisclosureKey`. */
88
+ disclosure?: ReadonlyMap<string, boolean>
89
+ onDisclosureChange?: (key: string, expanded: boolean) => void
90
+ }
91
+
92
+ type TurnPresentation = NonNullable<TranscriptCompositionRowContext['turn']>
93
+ type GroupPresentation = NonNullable<TranscriptCompositionRowContext['group']>
94
+
95
+ type FoldGroup<T> = Readonly<{
96
+ key: string
97
+ mode: TranscriptFoldMode
98
+ callCount: number
99
+ rows: readonly TranscriptRow<T>[]
100
+ }>
101
+
102
+ type RowRecord<T> = {
103
+ row: TranscriptRow<T>
104
+ index: number
105
+ displayKind: TranscriptDisplayKind
106
+ group?: GroupPresentation
107
+ turn?: TurnPresentation
108
+ fold?: TranscriptCompositionFold
109
+ foldControl?: FoldGroup<T>
110
+ }
111
+
112
+ type TranscriptLayout<T> = Readonly<{
113
+ rows: readonly RowRecord<T>[]
114
+ controls: readonly FoldGroup<T>[]
115
+ }>
116
+
117
+ type RowHandle<T> = {
118
+ key: string
119
+ get: Accessor<RowRecord<T>>
120
+ set: (next: RowRecord<T>) => void
121
+ last: RowRecord<T>
122
+ }
123
+
124
+ type DisclosureSignal = Readonly<{
125
+ get: Accessor<boolean | undefined>
126
+ set: (next: boolean | undefined) => void
127
+ }>
128
+
129
+ const encodeId = (value: string): string => encodeURIComponent(value)
130
+
131
+ /** Stable, transcript-scoped key for host-owned disclosure restoration. */
132
+ export function transcriptDisclosureKey(
133
+ resetKey: string | number | null | undefined,
134
+ mode: TranscriptFoldMode,
135
+ firstRowId: string
136
+ ): string {
137
+ return `transcript:${encodeId(String(resetKey ?? ''))}:${mode}:${encodeId(firstRowId)}`
138
+ }
139
+
140
+ function panelId(instanceId: string, resetKey: string, rowId: string): string {
141
+ return `${instanceId}-row-${encodeId(resetKey)}-${encodeId(rowId)}`
142
+ }
143
+
144
+ function buttonId(instanceId: string, resetKey: string, segmentKey: string): string {
145
+ return `${instanceId}-fold-${encodeId(resetKey)}-${encodeId(segmentKey)}`
146
+ }
147
+
148
+ function sameGroup(a?: GroupPresentation, b?: GroupPresentation): boolean {
149
+ return a === b || (!!a && !!b && a.index === b.index && a.size === b.size)
150
+ }
151
+
152
+ function sameTurn(a?: TurnPresentation, b?: TurnPresentation): boolean {
153
+ return (
154
+ a === b || (!!a && !!b && a.id === b.id && a.complete === b.complete && a.interim === b.interim)
155
+ )
156
+ }
157
+
158
+ function sameFold(a?: TranscriptCompositionFold, b?: TranscriptCompositionFold): boolean {
159
+ return (
160
+ a === b ||
161
+ (!!a &&
162
+ !!b &&
163
+ a.key === b.key &&
164
+ a.mode === b.mode &&
165
+ a.mounting === b.mounting &&
166
+ a.callCount === b.callCount)
167
+ )
168
+ }
169
+
170
+ function sameFoldGroup<T>(a?: FoldGroup<T>, b?: FoldGroup<T>): boolean {
171
+ return (
172
+ a === b ||
173
+ (!!a &&
174
+ !!b &&
175
+ a.key === b.key &&
176
+ a.callCount === b.callCount &&
177
+ a.rows.length === b.rows.length &&
178
+ a.rows.every((row, index) => row === b.rows[index]))
179
+ )
180
+ }
181
+
182
+ function sameRecord<T>(a: RowRecord<T>, b: RowRecord<T>): boolean {
183
+ return (
184
+ a.row === b.row &&
185
+ a.index === b.index &&
186
+ a.displayKind === b.displayKind &&
187
+ sameGroup(a.group, b.group) &&
188
+ sameTurn(a.turn, b.turn) &&
189
+ sameFold(a.fold, b.fold) &&
190
+ sameFoldGroup(a.foldControl, b.foldControl)
191
+ )
192
+ }
193
+
194
+ function firstRow<T>(item: TranscriptTurnItem<T>): TranscriptRow<T> | undefined {
195
+ return item.kind === 'single' ? item.row : item.rows[0]
196
+ }
197
+
198
+ function appendTurnItem<T>(
199
+ item: TranscriptTurnItem<T>,
200
+ displayKind: 'single' | 'group' | 'turn',
201
+ turn: TurnPresentation | undefined,
202
+ indices: ReadonlyMap<TranscriptRow<T>, number>,
203
+ into: RowRecord<T>[]
204
+ ): void {
205
+ if (item.kind === 'single') {
206
+ into.push({
207
+ row: item.row,
208
+ index: indices.get(item.row) ?? item.index,
209
+ displayKind,
210
+ turn,
211
+ })
212
+ return
213
+ }
214
+ item.rows.forEach((row, index) => {
215
+ into.push({
216
+ row,
217
+ index: indices.get(row) ?? item.startIndex + index,
218
+ displayKind,
219
+ group: { index, size: item.rows.length },
220
+ turn,
221
+ })
222
+ })
223
+ }
224
+
225
+ function appendDisplayItem<T>(
226
+ item: TranscriptDisplayItem<T>,
227
+ indices: ReadonlyMap<TranscriptRow<T>, number>,
228
+ into: RowRecord<T>[]
229
+ ): void {
230
+ if (item.kind !== 'turn') {
231
+ appendTurnItem(item, item.kind, undefined, indices, into)
232
+ return
233
+ }
234
+ const turnId = item.items.map(firstRow).find((row) => row)?.id ?? 'empty-turn'
235
+ const turn: TurnPresentation = {
236
+ id: turnId,
237
+ complete: item.complete,
238
+ interim: !!item.interim,
239
+ }
240
+ for (const child of item.items) appendTurnItem(child, 'turn', turn, indices, into)
241
+ }
242
+
243
+ function buildDisclosures<T>(
244
+ records: readonly RowRecord<T>[],
245
+ defaultMode: TranscriptFoldMode,
246
+ foldState: TranscriptFoldState,
247
+ resetKey: string
248
+ ): FoldGroup<T>[] {
249
+ const controls: FoldGroup<T>[] = []
250
+ let scope: RowRecord<T>[] = []
251
+ const flushScope = () => {
252
+ if (!scope.length) return
253
+ const rows = scope.map((record) => record.row)
254
+ const hasInterimTurn = scope.some((record) => record.turn?.interim)
255
+ const hasConclusion = scope.some((record) => record.row.conclusion)
256
+ const mode: TranscriptFoldMode = hasInterimTurn
257
+ ? 'interim'
258
+ : foldState === 'default'
259
+ ? defaultMode
260
+ : foldState === 'collapsed'
261
+ ? hasConclusion
262
+ ? 'prose'
263
+ : 'tools'
264
+ : 'interim'
265
+ const plan = planTranscriptFold(rows, { mode, settled: true })
266
+ const foldable = plan.entries.flatMap((entry, index) =>
267
+ entry.mounting === 'always' ? [] : [{ record: scope[index]!, mounting: entry.mounting }]
268
+ )
269
+ if (foldable.length) {
270
+ const anchorId = scope[0]?.row.id ?? foldable[0]!.record.row.id
271
+ const control: FoldGroup<T> = {
272
+ key: transcriptDisclosureKey(resetKey, mode, anchorId),
273
+ mode,
274
+ callCount: plan.callCount,
275
+ rows: foldable.map(({ record }) => record.row),
276
+ }
277
+ controls.push(control)
278
+ foldable.forEach(({ record, mounting }, index) => {
279
+ record.fold = {
280
+ key: control.key,
281
+ mode: control.mode,
282
+ mounting: foldState === 'collapsed' ? 'hide-when-collapsed' : mounting,
283
+ callCount: control.callCount,
284
+ }
285
+ if (index === 0) record.foldControl = control
286
+ })
287
+ }
288
+ scope = []
289
+ }
290
+
291
+ for (const record of records) {
292
+ if (record.row.opensTurn && scope.length) flushScope()
293
+ scope.push(record)
294
+ }
295
+ flushScope()
296
+ return controls
297
+ }
298
+
299
+ function foldLabel(mode: TranscriptFoldMode, count: number, expanded: boolean): string {
300
+ const subject =
301
+ mode === 'tools'
302
+ ? count === 1
303
+ ? '1 tool call'
304
+ : count > 1
305
+ ? `${count} tool calls`
306
+ : 'tool details'
307
+ : mode === 'prose'
308
+ ? 'earlier activity'
309
+ : 'interim activity'
310
+ return `${expanded ? 'Hide' : 'Show'} ${subject}`
311
+ }
312
+
313
+ function TranscriptRowEntry<T>(props: {
314
+ record: Accessor<RowRecord<T>>
315
+ renderRow: Component<TranscriptCompositionRendererProps<T>>
316
+ prefix: string
317
+ resetKey: Accessor<string>
318
+ expanded: (key: string) => Accessor<boolean>
319
+ running: Accessor<boolean>
320
+ foldState: Accessor<TranscriptFoldState>
321
+ onToggle: (key: string, expanded: boolean) => void
322
+ }) {
323
+ const current = props.record
324
+ const id = () => panelId(props.prefix, props.resetKey(), current().row.id)
325
+ const foldControl = () => current().foldControl
326
+ const disclosureKey = () => current().fold?.key
327
+ const expanded = () => {
328
+ const key = disclosureKey()
329
+ return key ? props.expanded(key)() : true
330
+ }
331
+ const collapsed = () => !!current().fold && !expanded()
332
+ const disabled = () => props.running() || props.foldState() !== 'default'
333
+ const context = (): TranscriptCompositionRowContext => ({
334
+ index: current().index,
335
+ displayKind: current().displayKind,
336
+ group: current().group,
337
+ turn: current().turn,
338
+ fold: current().fold,
339
+ })
340
+ const groupIndex = () => current().group?.index
341
+ const groupSize = () => current().group?.size
342
+ const renderable = () => current().fold?.mounting !== 'unmount-when-collapsed' || !collapsed()
343
+ const renderHostRow = () =>
344
+ createComponent(props.renderRow, {
345
+ get row() {
346
+ return current().row
347
+ },
348
+ get context() {
349
+ return context()
350
+ },
351
+ })
352
+ const controlLabel = () => {
353
+ const control = foldControl()
354
+ return control ? foldLabel(control.mode, control.callCount, expanded()) : ''
355
+ }
356
+ const controlRows = () =>
357
+ foldControl()
358
+ ?.rows.map((row) => panelId(props.prefix, props.resetKey(), row.id))
359
+ .join(' ')
360
+
361
+ return (
362
+ <div
363
+ data-slot="transcript-row"
364
+ data-display-kind={current().displayKind}
365
+ data-group-index={groupIndex()}
366
+ data-group-size={groupSize()}
367
+ class="flex min-w-0 flex-col gap-1"
368
+ >
369
+ <Show when={foldControl()}>
370
+ {(control) => (
371
+ <Button
372
+ id={buttonId(props.prefix, props.resetKey(), control().key)}
373
+ type="button"
374
+ variant="ghost"
375
+ size="sm"
376
+ class="w-full justify-start"
377
+ aria-label={controlLabel()}
378
+ aria-controls={controlRows()}
379
+ aria-expanded={expanded()}
380
+ aria-disabled={disabled() || undefined}
381
+ onClick={() => props.onToggle(control().key, !expanded())}
382
+ >
383
+ <ChevronDown aria-hidden="true" class="size-4 shrink-0" />
384
+ <span>{controlLabel()}</span>
385
+ </Button>
386
+ )}
387
+ </Show>
388
+ <div
389
+ id={id()}
390
+ data-slot="transcript-row-content"
391
+ hidden={collapsed()}
392
+ aria-hidden={collapsed() || undefined}
393
+ >
394
+ <Show when={renderable() ? current() : undefined}>
395
+ {(record) => {
396
+ record()
397
+ return renderHostRow()
398
+ }}
399
+ </Show>
400
+ </div>
401
+ </div>
402
+ )
403
+ }
404
+
405
+ /**
406
+ * Compose host-classified rows into stable turns, contiguous groups and disclosures.
407
+ * Row render owners are keyed by `id` in a single list, so loose rows keep their DOM
408
+ * owners when the grouping threshold promotes them into a turn. The caller owns row
409
+ * identity, protocol meaning, redaction, permissions and durable conversation state.
410
+ */
411
+ export function TranscriptComposition<T>(props: TranscriptCompositionProps<T>) {
412
+ const [local, rest] = splitProps(props, [
413
+ 'class',
414
+ 'rows',
415
+ 'renderRow',
416
+ 'running',
417
+ 'resetKey',
418
+ 'foldMode',
419
+ 'foldState',
420
+ 'onFoldStateChange',
421
+ 'disclosure',
422
+ 'onDisclosureChange',
423
+ ])
424
+ const instanceId = createUniqueId()
425
+ const grouper = createTranscriptGrouper<T>()
426
+ const [internalFoldState, setInternalFoldState] = createSignal<TranscriptFoldState>('default')
427
+ const rowHandles = new Map<string, RowHandle<T>>()
428
+ const disclosureSignals = new Map<string, DisclosureSignal>()
429
+ let lastResetKey = String(local.resetKey ?? '')
430
+
431
+ const resetKey = () => String(local.resetKey ?? '')
432
+ const foldState = () => local.foldState ?? internalFoldState()
433
+ const running = () => !!local.running
434
+ const localDisclosure = (key: string): DisclosureSignal => {
435
+ let signal = disclosureSignals.get(key)
436
+ if (!signal) {
437
+ const [get, set] = createSignal<boolean | undefined>(undefined)
438
+ signal = { get, set }
439
+ disclosureSignals.set(key, signal)
440
+ }
441
+ return signal
442
+ }
443
+ const disclosureExpanded = (key: string): Accessor<boolean> => {
444
+ const signal = localDisclosure(key)
445
+ return () => {
446
+ if (running()) return true
447
+ if (foldState() === 'collapsed') return false
448
+ if (foldState() === 'expanded') return true
449
+ return local.disclosure?.get(key) ?? signal.get() ?? false
450
+ }
451
+ }
452
+ const grouped = createMemo(() => grouper(local.rows))
453
+ const layout = createMemo<TranscriptLayout<T>>(() => {
454
+ const identity = resetKey()
455
+ if (identity !== lastResetKey) {
456
+ rowHandles.clear()
457
+ disclosureSignals.clear()
458
+ lastResetKey = identity
459
+ }
460
+ const indexByRow = new Map(local.rows.map((row, index) => [row, index]))
461
+ const display = applyTranscriptRunningState(grouped(), running())
462
+ const records: RowRecord<T>[] = []
463
+ display.forEach((item) => appendDisplayItem(item, indexByRow, records))
464
+ const controls = buildDisclosures(records, local.foldMode ?? 'tools', foldState(), identity)
465
+ return { rows: records, controls }
466
+ })
467
+ const handles = createMemo(() => {
468
+ const live = new Set<string>()
469
+ const identity = resetKey()
470
+ const result = layout().rows.map((record) => {
471
+ const key = `${identity}\u001f${record.row.id}`
472
+ live.add(key)
473
+ let handle = rowHandles.get(key)
474
+ if (!handle) {
475
+ const [get, set] = createSignal(record)
476
+ handle = { key, get, set, last: record }
477
+ rowHandles.set(key, handle)
478
+ } else if (!sameRecord(handle.last, record)) {
479
+ handle.last = record
480
+ handle.set(record)
481
+ }
482
+ return handle
483
+ })
484
+ for (const key of rowHandles.keys()) if (!live.has(key)) rowHandles.delete(key)
485
+ return result
486
+ })
487
+ const allControlIds = () =>
488
+ layout()
489
+ .controls.flatMap((control) =>
490
+ control.rows.map((row) => panelId(instanceId, resetKey(), row.id))
491
+ )
492
+ .join(' ')
493
+ const updateFoldState = () => {
494
+ if (running()) return
495
+ const current = foldState()
496
+ const next: TranscriptFoldState =
497
+ current === 'default' ? 'collapsed' : current === 'collapsed' ? 'expanded' : 'default'
498
+ if (local.foldState === undefined) setInternalFoldState(next)
499
+ local.onFoldStateChange?.(next)
500
+ }
501
+ const updateDisclosure = (key: string, expanded: boolean) => {
502
+ if (running() || foldState() !== 'default') return
503
+ if (local.disclosure) {
504
+ local.onDisclosureChange?.(key, expanded)
505
+ return
506
+ }
507
+ localDisclosure(key).set(expanded)
508
+ local.onDisclosureChange?.(key, expanded)
509
+ }
510
+
511
+ return (
512
+ <div
513
+ data-slot="transcript-composition"
514
+ data-fold-state={foldState()}
515
+ class={cn('flex w-full min-w-0 flex-col gap-1', local.class)}
516
+ {...rest}
517
+ >
518
+ <Show when={local.onFoldStateChange}>
519
+ <div class="flex justify-end">
520
+ <Button
521
+ id={`${instanceId}-fold-all`}
522
+ type="button"
523
+ variant="ghost"
524
+ size="sm"
525
+ aria-controls={allControlIds() || undefined}
526
+ aria-disabled={running() || undefined}
527
+ onClick={updateFoldState}
528
+ >
529
+ {foldState() === 'default'
530
+ ? 'Collapse all activity'
531
+ : foldState() === 'collapsed'
532
+ ? 'Expand all activity'
533
+ : 'Restore default folding'}
534
+ </Button>
535
+ </div>
536
+ </Show>
537
+ <div data-slot="transcript-rows" class="flex min-w-0 flex-col gap-1">
538
+ <For each={handles()}>
539
+ {(handle) => (
540
+ <TranscriptRowEntry
541
+ record={handle.get}
542
+ renderRow={local.renderRow}
543
+ prefix={instanceId}
544
+ resetKey={resetKey}
545
+ expanded={disclosureExpanded}
546
+ running={running}
547
+ foldState={foldState}
548
+ onToggle={updateDisclosure}
549
+ />
550
+ )}
551
+ </For>
552
+ </div>
553
+ </div>
554
+ )
555
+ }
@@ -0,0 +1,61 @@
1
+ /*
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * Licensed under the Apache License, Version 2.0.
4
+ *
5
+ * Adapted from KiroCrew TurnBlock.tsx at
6
+ * 283e136c0f902e965a535a7c9548c57c7504fed0. Host classifications replace
7
+ * protocol parsing; approval capability and payload ownership stay outside UI.
8
+ */
9
+ import type { TranscriptRow } from './transcript-grouping'
10
+
11
+ export type TranscriptFoldMode = 'tools' | 'prose' | 'interim'
12
+ export type TranscriptMounting = 'always' | 'unmount-when-collapsed' | 'hide-when-collapsed'
13
+ export type TranscriptFoldPlan<T> = Readonly<{
14
+ entries: readonly Readonly<{ row: TranscriptRow<T>; mounting: TranscriptMounting }>[]
15
+ callCount: number
16
+ foldableCount: number
17
+ }>
18
+
19
+ /**
20
+ * Ordered mount policy, shared by tool and prose disclosure. Unknown rows,
21
+ * actionable payloads and host-marked conclusions remain visible in every mode.
22
+ * A missing answer boundary keeps prose visible rather than implying that an
23
+ * answer appears elsewhere.
24
+ */
25
+ export function planTranscriptFold<T>(
26
+ rows: readonly TranscriptRow<T>[],
27
+ options: Readonly<{ mode: TranscriptFoldMode; settled: boolean }>
28
+ ): TranscriptFoldPlan<T> {
29
+ let conclusion = -1
30
+ for (let index = 0; index < rows.length; index++) if (rows[index]?.conclusion) conclusion = index
31
+ const foldEligible = (row: TranscriptRow<T>) =>
32
+ !row.alwaysVisible && !row.conclusion && row.fold !== undefined
33
+ const requestIds = new Set<string>()
34
+ let callCount = 0
35
+ for (const row of rows) {
36
+ if (foldEligible(row) && row.fold === 'tool' && row.call?.phase === 'request') {
37
+ const id = row.call.id
38
+ if (id === undefined || !requestIds.has(id)) {
39
+ callCount++
40
+ if (id !== undefined) requestIds.add(id)
41
+ }
42
+ }
43
+ }
44
+ let foldableCount = 0
45
+ const entries = rows.map((row, index) => {
46
+ const eligible = foldEligible(row)
47
+ let mounting: TranscriptMounting = 'always'
48
+ if (options.settled && eligible) {
49
+ if (options.mode === 'tools' && callCount > 0 && row.fold === 'tool')
50
+ mounting = 'unmount-when-collapsed'
51
+ else if (
52
+ options.mode === 'interim' ||
53
+ (options.mode === 'prose' && conclusion >= 0 && index < conclusion)
54
+ )
55
+ mounting = 'hide-when-collapsed'
56
+ }
57
+ if (mounting !== 'always') foldableCount++
58
+ return { row, mounting }
59
+ })
60
+ return { entries, callCount, foldableCount }
61
+ }